diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 57d5c9cbcf289..54c86193c83a6 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -161,6 +161,19 @@ jQuery( function( $ ) { issue.test = issue.status + count; } + if ( 'critical' === issue.status || 'recommended' === issue.status ) { + if ( 'undefined' === typeof SiteHealth.site_status.actionable_issues ) { + SiteHealth.site_status.actionable_issues = []; + } + + SiteHealth.site_status.actionable_issues.push( { + test: issue.test, + label: issue.label, + status: issue.status, + description: issue.description + } ); + } + if ( 'critical' === issue.status ) { heading = sprintf( _n( '%s critical issue', '%s critical issues', count ), @@ -250,13 +263,19 @@ jQuery( function( $ ) { } if ( isStatusTab ) { + var postData = { + 'action': 'health-check-site-status-result', + '_wpnonce': SiteHealth.nonce.site_status_result, + 'counts': SiteHealth.site_status.issues + }; + + if ( 'undefined' !== typeof SiteHealth.site_status.actionable_issues ) { + postData.issues = JSON.stringify( SiteHealth.site_status.actionable_issues ); + } + $.post( ajaxurl, - { - 'action': 'health-check-site-status-result', - '_wpnonce': SiteHealth.nonce.site_status_result, - 'counts': SiteHealth.site_status.issues - } + postData ); if ( 100 === val ) { @@ -375,6 +394,7 @@ jQuery( function( $ ) { 'recommended': 0, 'critical': 0 }; + SiteHealth.site_status.actionable_issues = []; } if ( 0 < SiteHealth.site_status.direct.length ) { diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..582e75e4783f3 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5466,7 +5466,59 @@ function wp_ajax_health_check_site_status_result() { wp_send_json_error(); } - set_transient( 'health-check-site-status-result', wp_json_encode( $_POST['counts'] ) ); + $counts = isset( $_POST['counts'] ) ? wp_unslash( $_POST['counts'] ) : null; + if ( ! is_array( $counts ) ) { + wp_send_json_error(); + } + + $good = isset( $counts['good'] ) ? (int) $counts['good'] : 0; + $recommended = isset( $counts['recommended'] ) ? (int) $counts['recommended'] : 0; + $critical = isset( $counts['critical'] ) ? (int) $counts['critical'] : 0; + + $payload = array( + 'good' => $good, + 'recommended' => $recommended, + 'critical' => $critical, + ); + + $previous_raw = get_transient( 'health-check-site-status-result' ); + $previous = is_string( $previous_raw ) ? json_decode( $previous_raw, true ) : array(); + $previous = is_array( $previous ) ? $previous : array(); + $previous_issues = ( isset( $previous['issues'] ) && is_array( $previous['issues'] ) ) ? $previous['issues'] : array(); + + if ( array_key_exists( 'issues', $_POST ) ) { + $issues_raw = wp_unslash( $_POST['issues'] ); + $decoded = is_string( $issues_raw ) ? json_decode( $issues_raw, true ) : null; + + if ( is_array( $decoded ) ) { + $sanitized_issues = array(); + foreach ( $decoded as $issue ) { + if ( ! is_array( $issue ) ) { + continue; + } + + $status = isset( $issue['status'] ) ? sanitize_key( $issue['status'] ) : ''; + if ( ! in_array( $status, array( 'recommended', 'critical' ), true ) ) { + continue; + } + + $sanitized_issues[] = array( + 'test' => isset( $issue['test'] ) ? sanitize_text_field( $issue['test'] ) : '', + 'label' => isset( $issue['label'] ) ? sanitize_text_field( $issue['label'] ) : '', + 'status' => $status, + 'description' => isset( $issue['description'] ) ? wp_strip_all_tags( $issue['description'] ) : '', + ); + } + + $payload['issues'] = $sanitized_issues; + } elseif ( $previous_issues ) { + $payload['issues'] = $previous_issues; + } + } elseif ( $previous_issues ) { + $payload['issues'] = $previous_issues; + } + + set_transient( 'health-check-site-status-result', wp_json_encode( $payload ) ); wp_send_json_success(); } diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 75e046ef8ffa7..ce023d5bc8567 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3453,7 +3453,12 @@ public function wp_cron_scheduled_check() { } } + $site_status['issues'] = array(); foreach ( $results as $result ) { + if ( ! is_array( $result ) || ! isset( $result['status'] ) ) { + continue; + } + if ( 'critical' === $result['status'] ) { ++$site_status['critical']; } elseif ( 'recommended' === $result['status'] ) { @@ -3461,6 +3466,15 @@ public function wp_cron_scheduled_check() { } else { ++$site_status['good']; } + + if ( in_array( $result['status'], array( 'recommended', 'critical' ), true ) ) { + $site_status['issues'][] = array( + 'test' => isset( $result['test'] ) ? (string) $result['test'] : '', + 'label' => isset( $result['label'] ) ? wp_strip_all_tags( (string) $result['label'] ) : '', + 'status' => $result['status'], + 'description' => isset( $result['description'] ) ? wp_strip_all_tags( (string) $result['description'] ) : '', + ); + } } set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 4c6db1ed830e0..00b62855ae2ba 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -196,53 +196,161 @@ function wp_register_core_abilities(): void { ) ); + $site_health_issue_properties = array( + 'test' => array( + 'type' => 'string', + 'title' => __( 'Site Health test identifier' ), + 'description' => __( 'The machine identifier for the Site Health test that produced this entry.' ), + ), + 'label' => array( + 'type' => 'string', + 'title' => __( 'Site Health issue label' ), + 'description' => __( 'A short title describing the Site Health issue.' ), + ), + 'status' => array( + 'type' => 'string', + 'title' => __( 'Site Health issue severity' ), + 'description' => __( 'Whether this entry is a recommended improvement or a critical issue.' ), + 'enum' => array( 'recommended', 'critical' ), + ), + 'description' => array( + 'type' => 'string', + 'title' => __( 'Site Health issue description' ), + 'description' => __( 'Plain text details for the Site Health issue, sourced from cached results.' ), + ), + ); + + $environment_info_properties = array( + 'environment' => array( + 'type' => 'string', + 'description' => __( 'The site\'s runtime environment classification (can be one of these: production, staging, development, local).' ), + 'enum' => array( 'production', 'staging', 'development', 'local' ), + ), + 'php_version' => array( + 'type' => 'string', + 'description' => __( 'The PHP runtime version executing WordPress.' ), + ), + 'db_server_info' => array( + 'type' => 'string', + 'description' => __( 'The database server vendor and version string reported by the driver.' ), + ), + 'wp_version' => array( + 'type' => 'string', + 'description' => __( 'The WordPress core version running on this site.' ), + ), + 'site_health' => array( + 'type' => 'object', + 'description' => __( 'A cached-only Site Health summary for agents: overall status, counts, and actionable issues.' ), + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'title' => __( 'Site Health overall status' ), + 'description' => __( 'unknown: no cached Site Health results yet. good: no recommended or critical findings. recommended or critical: matching severity is present in cached counts.' ), + 'enum' => array( 'unknown', 'good', 'recommended', 'critical' ), + ), + 'counts' => array( + 'type' => 'object', + 'title' => __( 'Site Health result counts' ), + 'description' => __( 'How many Site Health tests reported each status in the cached run.' ), + 'properties' => array( + 'good' => array( + 'type' => 'integer', + 'title' => __( 'Good results' ), + 'description' => __( 'Number of tests reporting a good status.' ), + ), + 'recommended' => array( + 'type' => 'integer', + 'title' => __( 'Recommended improvements' ), + 'description' => __( 'Number of tests recommending an improvement.' ), + ), + 'critical' => array( + 'type' => 'integer', + 'title' => __( 'Critical issues' ), + 'description' => __( 'Number of tests reporting a critical issue.' ), + ), + ), + 'additionalProperties' => false, + ), + 'issues' => array( + 'type' => 'array', + 'title' => __( 'Actionable Site Health issues' ), + 'description' => __( 'Up to ten recommended or critical issues from the cached Site Health run.' ), + 'items' => array( + 'type' => 'object', + 'properties' => $site_health_issue_properties, + 'additionalProperties' => false, + ), + ), + 'truncated' => array( + 'type' => 'boolean', + 'title' => __( 'Issues list truncated' ), + 'description' => __( 'True when more than ten actionable issues exist in the cache.' ), + ), + ), + 'additionalProperties' => false, + ), + ); + $environment_info_fields = array_keys( $environment_info_properties ); + wp_register_ability( 'core/get-environment-info', array( 'label' => __( 'Get Environment Info' ), - 'description' => __( 'Returns core details about the site\'s runtime context for diagnostics and compatibility (environment, PHP runtime, database server info, WordPress version).' ), + 'description' => __( 'Returns core details about the site\'s runtime context for diagnostics and compatibility (environment, PHP runtime, database server info, WordPress version), plus an optional cached Site Health summary.' ), 'category' => $category_site, - 'output_schema' => array( + 'input_schema' => array( 'type' => 'object', - 'required' => array( 'environment', 'php_version', 'db_server_info', 'wp_version' ), 'properties' => array( - 'environment' => array( - 'type' => 'string', - 'description' => __( 'The site\'s runtime environment classification (can be one of these: production, staging, development, local).' ), - 'enum' => array( 'production', 'staging', 'development', 'local' ), - ), - 'php_version' => array( - 'type' => 'string', - 'description' => __( 'The PHP runtime version executing WordPress.' ), - ), - 'db_server_info' => array( - 'type' => 'string', - 'description' => __( 'The database server vendor and version string reported by the driver.' ), - ), - 'wp_version' => array( - 'type' => 'string', - 'description' => __( 'The WordPress core version running on this site.' ), + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $environment_info_fields, + ), + 'description' => __( 'Optional: Limit response to specific fields. If omitted, all fields are returned.' ), ), ), 'additionalProperties' => false, + 'default' => array(), ), - 'execute_callback' => static function (): array { + 'output_schema' => array( + 'type' => 'object', + 'properties' => $environment_info_properties, + 'additionalProperties' => false, + ), + 'execute_callback' => static function ( $input = array() ) use ( $environment_info_fields ): array { global $wpdb; - $env = wp_get_environment_type(); - $php_version = phpversion(); - $db_server_info = ''; - if ( method_exists( $wpdb, 'db_server_info' ) ) { - $db_server_info = $wpdb->db_server_info() ?? ''; + $input = is_array( $input ) ? $input : array(); + $requested = ! empty( $input['fields'] ) ? $input['fields'] : $environment_info_fields; + + $result = array(); + + if ( in_array( 'environment', $requested, true ) ) { + $result['environment'] = wp_get_environment_type(); } - $wp_version = get_bloginfo( 'version' ); - return array( - 'environment' => $env, - 'php_version' => $php_version, - 'db_server_info' => $db_server_info, - 'wp_version' => $wp_version, - ); + if ( in_array( 'php_version', $requested, true ) ) { + $result['php_version'] = phpversion(); + } + + if ( in_array( 'db_server_info', $requested, true ) ) { + $db_server_info = ''; + if ( method_exists( $wpdb, 'db_server_info' ) ) { + $db_server_info = $wpdb->db_server_info() ?? ''; + } + $result['db_server_info'] = $db_server_info; + } + + if ( in_array( 'wp_version', $requested, true ) ) { + $result['wp_version'] = get_bloginfo( 'version' ); + } + + if ( in_array( 'site_health', $requested, true ) ) { + $result['site_health'] = wp_get_abilities_api_site_health_summary_from_cache(); + } + + return $result; }, 'permission_callback' => static function (): bool { return current_user_can( 'manage_options' ); @@ -258,3 +366,84 @@ function wp_register_core_abilities(): void { ) ); } + +/** + * Builds the Site Health portion of `core/get-environment-info` from cached results only. + * + * @since tbd + * + * @return array{ + * status: 'unknown'|'good'|'recommended'|'critical', + * counts: array{good: int, recommended: int, critical: int}, + * issues: array, + * truncated: bool + * } + */ +function wp_get_abilities_api_site_health_summary_from_cache(): array { + $unknown = array( + 'status' => 'unknown', + 'counts' => array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + 'issues' => array(), + 'truncated' => false, + ); + + $cached = get_transient( 'health-check-site-status-result' ); + + if ( false === $cached ) { + return $unknown; + } + + $data = json_decode( $cached, true ); + if ( ! is_array( $data ) ) { + return $unknown; + } + + $counts = array( + 'good' => isset( $data['good'] ) ? (int) $data['good'] : 0, + 'recommended' => isset( $data['recommended'] ) ? (int) $data['recommended'] : 0, + 'critical' => isset( $data['critical'] ) ? (int) $data['critical'] : 0, + ); + + $stored = array(); + if ( isset( $data['issues'] ) && is_array( $data['issues'] ) ) { + foreach ( $data['issues'] as $issue ) { + if ( ! is_array( $issue ) ) { + continue; + } + + $status = isset( $issue['status'] ) ? (string) $issue['status'] : ''; + if ( ! in_array( $status, array( 'recommended', 'critical' ), true ) ) { + continue; + } + + $stored[] = array( + 'test' => isset( $issue['test'] ) ? (string) $issue['test'] : '', + 'label' => isset( $issue['label'] ) ? (string) $issue['label'] : '', + 'status' => $status, + 'description' => isset( $issue['description'] ) ? (string) $issue['description'] : '', + ); + } + } + + $total_stored = count( $stored ); + $truncated = $total_stored > 10; + $issues = array_slice( $stored, 0, 10 ); + + $status = 'good'; + if ( $counts['critical'] > 0 ) { + $status = 'critical'; + } elseif ( $counts['recommended'] > 0 ) { + $status = 'recommended'; + } + + return array( + 'status' => $status, + 'counts' => $counts, + 'issues' => $issues, + 'truncated' => $truncated, + ); +} diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 48cae6efd1dee..708b675f3cd3d 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -156,7 +156,9 @@ public function test_core_get_current_user_info_returns_user_data(): void { /** * Tests executing the environment info ability. + * * @ticket 64146 + * @ticket 65232 */ public function test_core_get_environment_info_executes(): void { // Requires manage_options. @@ -172,7 +174,114 @@ public function test_core_get_environment_info_executes(): void { $this->assertArrayHasKey( 'php_version', $ability_data ); $this->assertArrayHasKey( 'db_server_info', $ability_data ); $this->assertArrayHasKey( 'wp_version', $ability_data ); + $this->assertArrayHasKey( 'site_health', $ability_data ); $this->assertSame( $environment, $ability_data['environment'] ); + $this->assertSame( 'unknown', $ability_data['site_health']['status'] ); + } + + /** + * Tests that the `fields` input limits `core/get-environment-info` output. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_fields_filtering(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $data = $ability->execute( + array( + 'fields' => array( 'php_version' ), + ) + ); + + $this->assertCount( 1, $data ); + $this->assertArrayHasKey( 'php_version', $data ); + $this->assertArrayNotHasKey( 'environment', $data ); + } + + /** + * Tests `site_health` in `core/get-environment-info` when the Site Health transient is populated. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_from_cache(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + set_transient( + 'health-check-site-status-result', + wp_json_encode( + array( + 'good' => 8, + 'recommended' => 1, + 'critical' => 0, + 'issues' => array( + array( + 'test' => 'wordpress_version', + 'label' => 'WordPress update available', + 'status' => 'recommended', + 'description' => 'A new version of WordPress is available.', + ), + ), + ) + ) + ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $data = $ability->execute( + array( + 'fields' => array( 'site_health' ), + ) + ); + + $this->assertArrayHasKey( 'site_health', $data ); + $health = $data['site_health']; + $this->assertSame( 'recommended', $health['status'] ); + $this->assertSame( 8, $health['counts']['good'] ); + $this->assertSame( 1, $health['counts']['recommended'] ); + $this->assertSame( 0, $health['counts']['critical'] ); + $this->assertCount( 1, $health['issues'] ); + $this->assertSame( 'wordpress_version', $health['issues'][0]['test'] ); + $this->assertFalse( $health['truncated'] ); + } + + /** + * Tests that actionable Site Health issues are capped with `truncated` when the cache holds more than ten. + * + * @ticket 65232 + */ + public function test_core_get_environment_info_site_health_issues_truncated(): void { + $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $admin_id ); + + $issues = array(); + for ( $i = 0; $i < 15; $i++ ) { + $issues[] = array( + 'test' => 'test_' . $i, + 'label' => 'Issue ' . $i, + 'status' => 'recommended', + 'description' => 'Description ' . $i, + ); + } + + set_transient( + 'health-check-site-status-result', + wp_json_encode( + array( + 'good' => 0, + 'recommended' => 15, + 'critical' => 0, + 'issues' => $issues, + ) + ) + ); + + $ability = wp_get_ability( 'core/get-environment-info' ); + $data = $ability->execute( array( 'fields' => array( 'site_health' ) ) ); + + $this->assertTrue( $data['site_health']['truncated'] ); + $this->assertCount( 10, $data['site_health']['issues'] ); } /**