From 08ee01bb4bbe6b6ab577443f7cf15f8dfc8d5c79 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 15 Jun 2026 15:53:19 +0200 Subject: [PATCH 01/40] Site Health: Cache the full results and a timestamp in the status transient. Previously the `health-check-site-status-result` transient stored only the aggregate `good`, `recommended`, and `critical` counts, so any consumer that needed the underlying results had to re-run the Site Health tests synchronously. The scheduled check now caches the complete results array and the time they were collected alongside the counts. The Site Health screen AJAX handler validates the submitted counts and preserves the cached results and timestamp while refreshing the counts, and `enqueue_scripts()` only localizes the counts to avoid embedding the full results in every Site Health and Dashboard page. This provides a reusable cached source of detailed Site Health data for the dashboard, the admin menu, and future REST/Abilities consumers without triggering synchronous tests. Adds unit tests covering the scheduled-check caching and the AJAX result handler. See #65232. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wp-admin/includes/ajax-actions.php | 34 +++- .../includes/class-wp-site-health.php | 30 ++- tests/phpunit/tests/admin/wpSiteHealth.php | 111 +++++++++++ .../wpAjaxHealthCheckSiteStatusResult.php | 179 ++++++++++++++++++ 4 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..efe02ef7fad1f 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5458,6 +5458,8 @@ function wp_ajax_health_check_loopback_requests() { * Handles site health check to update the result status via AJAX. * * @since 5.2.0 + * @since 7.1.0 The submitted counts are validated, and the full test results and + * collection timestamp cached by the scheduled check are preserved. */ function wp_ajax_health_check_site_status_result() { check_ajax_referer( 'health-check-site-status-result' ); @@ -5466,7 +5468,37 @@ 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(); + } + + $site_status = array( + 'good' => isset( $counts['good'] ) ? (int) $counts['good'] : 0, + 'recommended' => isset( $counts['recommended'] ) ? (int) $counts['recommended'] : 0, + 'critical' => isset( $counts['critical'] ) ? (int) $counts['critical'] : 0, + ); + + /* + * Only the aggregate counts are refreshed here. Preserve the full test results + * and the timestamp recorded by the scheduled check so they are not discarded + * when the counts are updated from the Site Health screen. + */ + $cached = get_transient( 'health-check-site-status-result' ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + if ( is_array( $cached ) ) { + if ( isset( $cached['results'] ) && is_array( $cached['results'] ) ) { + $site_status['results'] = $cached['results']; + } + + if ( isset( $cached['timestamp'] ) ) { + $site_status['timestamp'] = (int) $cached['timestamp']; + } + } + + set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); 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 9a888f231816c..da89b3c105da6 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -115,9 +115,20 @@ public function enqueue_scripts() { $issue_counts = get_transient( 'health-check-site-status-result' ); if ( false !== $issue_counts ) { - $issue_counts = json_decode( $issue_counts ); + $issue_counts = json_decode( $issue_counts, true ); - $health_check_js_variables['site_status']['issues'] = $issue_counts; + /* + * The cached result also stores the full test results and a timestamp. + * Only the aggregate counts are needed on the client, so avoid localizing + * the rest of the payload. + */ + if ( is_array( $issue_counts ) ) { + $health_check_js_variables['site_status']['issues'] = array( + 'good' => isset( $issue_counts['good'] ) ? (int) $issue_counts['good'] : 0, + 'recommended' => isset( $issue_counts['recommended'] ) ? (int) $issue_counts['recommended'] : 0, + 'critical' => isset( $issue_counts['critical'] ) ? (int) $issue_counts['critical'] : 0, + ); + } } if ( 'site-health' === $screen->id && ( ! isset( $_GET['tab'] ) || empty( $_GET['tab'] ) ) ) { @@ -3359,6 +3370,9 @@ public function maybe_create_scheduled_event() { * Runs the scheduled event to check and update the latest site health status for the website. * * @since 5.4.0 + * @since 7.1.0 The cached result also stores the full test results under `results` + * and a `timestamp` indicating when they were collected, in addition + * to the aggregate `good`, `recommended`, and `critical` counts. */ public function wp_cron_scheduled_check() { // Bootstrap wp-admin, as WP_Cron doesn't do this for us. @@ -3454,6 +3468,10 @@ public function wp_cron_scheduled_check() { } foreach ( $results as $result ) { + if ( ! is_array( $result ) || ! isset( $result['status'] ) ) { + continue; + } + if ( 'critical' === $result['status'] ) { ++$site_status['critical']; } elseif ( 'recommended' === $result['status'] ) { @@ -3463,6 +3481,14 @@ public function wp_cron_scheduled_check() { } } + /* + * Cache the full results alongside the aggregate counts so consumers can read + * the detailed Site Health status without re-running the tests, and record when + * the results were collected so their freshness can be evaluated. + */ + $site_status['results'] = $results; + $site_status['timestamp'] = time(); + set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); } diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..fee814bceae20 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -707,4 +707,115 @@ public function test_get_test_opcode_cache_result_by_environment() { $this->assertStringContainsString( __( 'Enabling this cache can significantly improve the performance of your site.' ), $result['description'] ); } } + + /** + * Ensures the scheduled check caches the full results and a timestamp. + * + * The cached `health-check-site-status-result` transient must contain the + * aggregate counts, the complete (unreduced) test results, and the time the + * results were collected, so consumers can read the detailed Site Health + * status without re-running the tests. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + */ + public function test_wp_cron_scheduled_check_caches_full_results() { + $tests = array( + 'direct' => array( + 'fake_critical' => array( + 'label' => 'Fake critical', + 'test' => static function () { + return array( + 'label' => 'Critical label', + 'status' => 'critical', + 'badge' => array( + 'label' => 'Security', + 'color' => 'red', + ), + 'description' => '

Critical description.

', + 'actions' => '', + 'test' => 'fake_critical', + ); + }, + ), + 'fake_recommended' => array( + 'label' => 'Fake recommended', + 'test' => static function () { + return array( + 'label' => 'Recommended label', + 'status' => 'recommended', + 'description' => '

Recommended description.

', + 'test' => 'fake_recommended', + ); + }, + ), + 'fake_good' => array( + 'label' => 'Fake good', + 'test' => static function () { + return array( + 'label' => 'Good label', + 'status' => 'good', + 'description' => '

Good description.

', + 'test' => 'fake_good', + ); + }, + ), + ), + 'async' => array(), + ); + + $filter = static function () use ( $tests ) { + return $tests; + }; + + add_filter( 'site_status_tests', $filter ); + + delete_transient( 'health-check-site-status-result' ); + + $before = time(); + $this->instance->wp_cron_scheduled_check(); + $after = time(); + + remove_filter( 'site_status_tests', $filter ); + + $cached = json_decode( get_transient( 'health-check-site-status-result' ), true ); + + $this->assertIsArray( $cached, 'The cached result should decode to an array.' ); + + // Aggregate counts are preserved at the top level. + $this->assertSame( 1, $cached['good'], 'There should be one good result.' ); + $this->assertSame( 1, $cached['recommended'], 'There should be one recommended result.' ); + $this->assertSame( 1, $cached['critical'], 'There should be one critical result.' ); + + // The full results are cached, including the passing (good) result. + $this->assertArrayHasKey( 'results', $cached, 'The full results should be cached.' ); + $this->assertCount( 3, $cached['results'], 'All test results should be cached, not just actionable ones.' ); + + $results_by_test = array(); + foreach ( $cached['results'] as $result ) { + $results_by_test[ $result['test'] ] = $result; + } + + $this->assertSame( + array( 'fake_critical', 'fake_recommended', 'fake_good' ), + array_keys( $results_by_test ), + 'Every test result should be cached.' + ); + + // The complete result is stored as produced, without stripping HTML. + $this->assertSame( 'critical', $results_by_test['fake_critical']['status'] ); + $this->assertSame( + '

Critical description.

', + $results_by_test['fake_critical']['description'], + 'The full result should be cached without reducing or stripping it.' + ); + $this->assertArrayHasKey( 'badge', $results_by_test['fake_critical'], 'All result fields should be cached.' ); + + // A timestamp records when the results were collected. + $this->assertArrayHasKey( 'timestamp', $cached, 'A collection timestamp should be cached.' ); + $this->assertIsInt( $cached['timestamp'] ); + $this->assertGreaterThanOrEqual( $before, $cached['timestamp'] ); + $this->assertLessThanOrEqual( $after, $cached['timestamp'] ); + } } diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php new file mode 100644 index 0000000000000..020b341acaa4c --- /dev/null +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -0,0 +1,179 @@ +_last_response = ''; + + try { + $this->_handleAjax( self::TRANSIENT ); + } catch ( WPAjaxDieContinueException $e ) { + unset( $e ); + } + + return json_decode( $this->_last_response, true ); + } + + /** + * The browser only refreshes the counts, so the full results and timestamp + * cached by the scheduled check must be preserved. + * + * @ticket 65232 + */ + public function test_refreshing_counts_preserves_cached_results_and_timestamp() { + $timestamp = 1715714399; + $results = array( + array( + 'test' => 'fake_critical', + 'label' => 'Critical label', + 'status' => 'critical', + 'description' => '

Critical description.

', + ), + ); + + set_transient( + self::TRANSIENT, + wp_json_encode( + array( + 'good' => 5, + 'recommended' => 0, + 'critical' => 1, + 'results' => $results, + 'timestamp' => $timestamp, + ) + ) + ); + + $this->_setRole( 'administrator' ); + $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); + $_POST['counts'] = array( + 'good' => 6, + 'recommended' => 2, + 'critical' => 0, + ); + + $response = $this->dispatch_result_request(); + + $this->assertTrue( $response['success'] ); + + $cached = json_decode( get_transient( self::TRANSIENT ), true ); + + // The aggregate counts are refreshed from the request. + $this->assertSame( 6, $cached['good'] ); + $this->assertSame( 2, $cached['recommended'] ); + $this->assertSame( 0, $cached['critical'] ); + + // The results collected by the scheduled check and their timestamp are kept intact. + $this->assertSame( $results, $cached['results'], 'Cached results should be preserved.' ); + $this->assertSame( $timestamp, $cached['timestamp'], 'The timestamp should not be changed by a counts update.' ); + } + + /** + * When nothing has been cached yet, only the counts are stored. + * + * @ticket 65232 + */ + public function test_storing_counts_without_cached_results() { + delete_transient( self::TRANSIENT ); + + $this->_setRole( 'administrator' ); + $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); + $_POST['counts'] = array( + 'good' => 3, + 'recommended' => 1, + 'critical' => 0, + ); + + $response = $this->dispatch_result_request(); + + $this->assertTrue( $response['success'] ); + + $cached = json_decode( get_transient( self::TRANSIENT ), true ); + + $this->assertSame( 3, $cached['good'] ); + $this->assertSame( 1, $cached['recommended'] ); + $this->assertSame( 0, $cached['critical'] ); + $this->assertArrayNotHasKey( 'results', $cached ); + $this->assertArrayNotHasKey( 'timestamp', $cached ); + } + + /** + * Non-array counts are rejected and leave the cached result untouched. + * + * @ticket 65232 + */ + public function test_invalid_counts_return_error_and_leave_cache_untouched() { + $existing = array( 'good' => 9 ); + set_transient( self::TRANSIENT, wp_json_encode( $existing ) ); + + $this->_setRole( 'administrator' ); + $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); + // No 'counts' are supplied. + + $response = $this->dispatch_result_request(); + + $this->assertFalse( $response['success'] ); + + $cached = json_decode( get_transient( self::TRANSIENT ), true ); + $this->assertSame( $existing, $cached, 'The cached result should be untouched.' ); + } + + /** + * Users without the capability cannot write to the cache. + * + * @ticket 65232 + */ + public function test_user_without_capability_cannot_write_cache() { + delete_transient( self::TRANSIENT ); + + $this->_setRole( 'subscriber' ); + $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); + $_POST['counts'] = array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ); + + $response = $this->dispatch_result_request(); + + $this->assertFalse( $response['success'] ); + $this->assertFalse( get_transient( self::TRANSIENT ), 'No cache should be written for an unauthorized user.' ); + } +} From b8ee347d102eba90a9a870a63f42470f229e0777 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 15 Jun 2026 16:49:51 +0200 Subject: [PATCH 02/40] Tests: Fix Site Health AJAX tests on multisite --- .../wpAjaxHealthCheckSiteStatusResult.php | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index 020b341acaa4c..63fefc2f7c739 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -24,6 +24,13 @@ class Tests_Ajax_wpAjaxHealthCheckSiteStatusResult extends WP_Ajax_UnitTestCase */ const TRANSIENT = 'health-check-site-status-result'; + /** + * User ID granted super admin privileges during a multisite test. + * + * @var int + */ + private $super_admin_user_id = 0; + /** * Sets up the test fixture. */ @@ -34,6 +41,30 @@ public function set_up() { add_action( 'wp_ajax_' . self::TRANSIENT, 'wp_ajax_health_check_site_status_result', 1 ); } + /** + * Cleans up the test fixture. + */ + public function tear_down() { + if ( is_multisite() && $this->super_admin_user_id ) { + revoke_super_admin( $this->super_admin_user_id ); + $this->super_admin_user_id = 0; + } + + parent::tear_down(); + } + + /** + * Sets the current user to one that can view Site Health checks. + */ + private function set_user_with_site_health_capability() { + $this->_setRole( 'administrator' ); + + if ( is_multisite() ) { + $this->super_admin_user_id = get_current_user_id(); + grant_super_admin( $this->super_admin_user_id ); + } + } + /** * Dispatches the Ajax request and returns the decoded JSON response. * @@ -81,7 +112,7 @@ public function test_refreshing_counts_preserves_cached_results_and_timestamp() ) ); - $this->_setRole( 'administrator' ); + $this->set_user_with_site_health_capability(); $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); $_POST['counts'] = array( 'good' => 6, @@ -113,7 +144,7 @@ public function test_refreshing_counts_preserves_cached_results_and_timestamp() public function test_storing_counts_without_cached_results() { delete_transient( self::TRANSIENT ); - $this->_setRole( 'administrator' ); + $this->set_user_with_site_health_capability(); $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); $_POST['counts'] = array( 'good' => 3, @@ -143,7 +174,7 @@ public function test_invalid_counts_return_error_and_leave_cache_untouched() { $existing = array( 'good' => 9 ); set_transient( self::TRANSIENT, wp_json_encode( $existing ) ); - $this->_setRole( 'administrator' ); + $this->set_user_with_site_health_capability(); $_POST['_wpnonce'] = wp_create_nonce( self::TRANSIENT ); // No 'counts' are supplied. From d3a5e8a2abe624506710eafc93d9256dd69945a9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:37:03 -0700 Subject: [PATCH 03/40] Fix PHPStan `argument.type` error for mixed being passed to `json_encode()` --- src/wp-admin/includes/class-wp-site-health.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index da89b3c105da6..3314959d29746 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -114,7 +114,7 @@ public function enqueue_scripts() { $issue_counts = get_transient( 'health-check-site-status-result' ); - if ( false !== $issue_counts ) { + if ( is_string( $issue_counts ) ) { $issue_counts = json_decode( $issue_counts, true ); /* From 24aa96a74dbb71d97c968bf958d3fc47cb902b51 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:40:28 -0700 Subject: [PATCH 04/40] Fix PHPStan `argument.type` error for `wp_unslash()` expecting `array|string`, but `mixed` given. --- src/wp-admin/includes/ajax-actions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index efe02ef7fad1f..395ba4e23d0f6 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5468,7 +5468,7 @@ function wp_ajax_health_check_site_status_result() { wp_send_json_error(); } - $counts = isset( $_POST['counts'] ) ? wp_unslash( $_POST['counts'] ) : null; + $counts = isset( $_POST['counts'] ) && is_array( $_POST['counts'] ) ? wp_unslash( $_POST['counts'] ) : null; if ( ! is_array( $counts ) ) { wp_send_json_error(); From 26bf323dd0142f5996806bbd7c21c93ae8eaa8e8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:41:48 -0700 Subject: [PATCH 05/40] Simply accessing counts --- src/wp-admin/includes/ajax-actions.php | 6 +++--- src/wp-admin/includes/class-wp-site-health.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 395ba4e23d0f6..ea4d76c338fd3 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5475,9 +5475,9 @@ function wp_ajax_health_check_site_status_result() { } $site_status = array( - 'good' => isset( $counts['good'] ) ? (int) $counts['good'] : 0, - 'recommended' => isset( $counts['recommended'] ) ? (int) $counts['recommended'] : 0, - 'critical' => isset( $counts['critical'] ) ? (int) $counts['critical'] : 0, + 'good' => (int) ( $counts['good'] ?? 0 ), + 'recommended' => (int) ( $counts['recommended'] ?? 0 ), + 'critical' => (int) ( $counts['critical'] ?? 0 ), ); /* diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 3314959d29746..52d9f1a3ee6c3 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -124,9 +124,9 @@ public function enqueue_scripts() { */ if ( is_array( $issue_counts ) ) { $health_check_js_variables['site_status']['issues'] = array( - 'good' => isset( $issue_counts['good'] ) ? (int) $issue_counts['good'] : 0, - 'recommended' => isset( $issue_counts['recommended'] ) ? (int) $issue_counts['recommended'] : 0, - 'critical' => isset( $issue_counts['critical'] ) ? (int) $issue_counts['critical'] : 0, + 'good' => (int) ( $issue_counts['good'] ?? 0 ), + 'recommended' => (int) ( $issue_counts['recommended'] ?? 0 ), + 'critical' => (int) ( $issue_counts['critical'] ?? 0 ), ); } } From 768e598aa2da46f9f099d7a3ff1d94227b59f631 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:49:59 -0700 Subject: [PATCH 06/40] Add void return type hints --- tests/phpunit/tests/admin/wpSiteHealth.php | 2 +- .../ajax/wpAjaxHealthCheckSiteStatusResult.php | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index fee814bceae20..ce749582d8f74 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -720,7 +720,7 @@ public function test_get_test_opcode_cache_result_by_environment() { * * @covers ::wp_cron_scheduled_check */ - public function test_wp_cron_scheduled_check_caches_full_results() { + public function test_wp_cron_scheduled_check_caches_full_results(): void { $tests = array( 'direct' => array( 'fake_critical' => array( diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index 63fefc2f7c739..d791352046172 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -34,7 +34,7 @@ class Tests_Ajax_wpAjaxHealthCheckSiteStatusResult extends WP_Ajax_UnitTestCase /** * Sets up the test fixture. */ - public function set_up() { + public function set_up(): void { parent::set_up(); // This Ajax action is not part of the core actions registered by the base test case. @@ -44,7 +44,7 @@ public function set_up() { /** * Cleans up the test fixture. */ - public function tear_down() { + public function tear_down(): void { if ( is_multisite() && $this->super_admin_user_id ) { revoke_super_admin( $this->super_admin_user_id ); $this->super_admin_user_id = 0; @@ -56,7 +56,7 @@ public function tear_down() { /** * Sets the current user to one that can view Site Health checks. */ - private function set_user_with_site_health_capability() { + private function set_user_with_site_health_capability(): void { $this->_setRole( 'administrator' ); if ( is_multisite() ) { @@ -88,7 +88,7 @@ private function dispatch_result_request() { * * @ticket 65232 */ - public function test_refreshing_counts_preserves_cached_results_and_timestamp() { + public function test_refreshing_counts_preserves_cached_results_and_timestamp(): void { $timestamp = 1715714399; $results = array( array( @@ -141,7 +141,7 @@ public function test_refreshing_counts_preserves_cached_results_and_timestamp() * * @ticket 65232 */ - public function test_storing_counts_without_cached_results() { + public function test_storing_counts_without_cached_results(): void { delete_transient( self::TRANSIENT ); $this->set_user_with_site_health_capability(); @@ -170,7 +170,7 @@ public function test_storing_counts_without_cached_results() { * * @ticket 65232 */ - public function test_invalid_counts_return_error_and_leave_cache_untouched() { + public function test_invalid_counts_return_error_and_leave_cache_untouched(): void { $existing = array( 'good' => 9 ); set_transient( self::TRANSIENT, wp_json_encode( $existing ) ); @@ -191,7 +191,7 @@ public function test_invalid_counts_return_error_and_leave_cache_untouched() { * * @ticket 65232 */ - public function test_user_without_capability_cannot_write_cache() { + public function test_user_without_capability_cannot_write_cache(): void { delete_transient( self::TRANSIENT ); $this->_setRole( 'subscriber' ); From 1914559e5b9dfd8c731a8d5bfba8deeb6e75090b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:51:09 -0700 Subject: [PATCH 07/40] Update dispatch_result_request method to always return array --- .../phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index d791352046172..9d2ac0245ea57 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -68,9 +68,9 @@ private function set_user_with_site_health_capability(): void { /** * Dispatches the Ajax request and returns the decoded JSON response. * - * @return array|null The decoded response, or null when no JSON was returned. + * @return array{ success: bool, ... } The decoded response. */ - private function dispatch_result_request() { + private function dispatch_result_request(): array { $this->_last_response = ''; try { From 46c32aad110050f9a7204bc98f4f3993aed6931a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:51:50 -0700 Subject: [PATCH 08/40] Use native property type hint --- .../phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index 9d2ac0245ea57..0662d9b5c8ae9 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -26,10 +26,8 @@ class Tests_Ajax_wpAjaxHealthCheckSiteStatusResult extends WP_Ajax_UnitTestCase /** * User ID granted super admin privileges during a multisite test. - * - * @var int */ - private $super_admin_user_id = 0; + private int $super_admin_user_id = 0; /** * Sets up the test fixture. From 4572595850c15987dfca0bd71d34f3d153305eb6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:53:09 -0700 Subject: [PATCH 09/40] fixup! Update dispatch_result_request method to always return array --- .../phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index 0662d9b5c8ae9..bbf8e1e141d5e 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -77,7 +77,9 @@ private function dispatch_result_request(): array { unset( $e ); } - return json_decode( $this->_last_response, true ); + $response = json_decode( $this->_last_response, true ); + assert( is_array( $response ) ); + return $response; } /** From 75cc4e8d2f4dad5fad7a4fafd1ccab14dfb604e7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 12:11:00 -0700 Subject: [PATCH 10/40] Fix PHPStan issues in tests --- tests/phpunit/tests/admin/wpSiteHealth.php | 11 ++++++++++- .../ajax/wpAjaxHealthCheckSiteStatusResult.php | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index ce749582d8f74..f17edb3b1b7d7 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -779,9 +779,14 @@ public function test_wp_cron_scheduled_check_caches_full_results(): void { remove_filter( 'site_status_tests', $filter ); - $cached = json_decode( get_transient( 'health-check-site-status-result' ), true ); + $transient = get_transient( 'health-check-site-status-result' ); + $this->assertIsString( $transient ); + $cached = json_decode( $transient, true ); $this->assertIsArray( $cached, 'The cached result should decode to an array.' ); + $this->assertArrayHasKey( 'good', $cached ); + $this->assertArrayHasKey( 'recommended', $cached ); + $this->assertArrayHasKey( 'critical', $cached ); // Aggregate counts are preserved at the top level. $this->assertSame( 1, $cached['good'], 'There should be one good result.' ); @@ -790,10 +795,14 @@ public function test_wp_cron_scheduled_check_caches_full_results(): void { // The full results are cached, including the passing (good) result. $this->assertArrayHasKey( 'results', $cached, 'The full results should be cached.' ); + $this->assertIsArray( $cached['results'] ); $this->assertCount( 3, $cached['results'], 'All test results should be cached, not just actionable ones.' ); $results_by_test = array(); foreach ( $cached['results'] as $result ) { + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'test', $result ); + $this->assertIsString( $result['test'] ); $results_by_test[ $result['test'] ] = $result; } diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index bbf8e1e141d5e..4fc24ed5216a8 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -124,7 +124,10 @@ public function test_refreshing_counts_preserves_cached_results_and_timestamp(): $this->assertTrue( $response['success'] ); - $cached = json_decode( get_transient( self::TRANSIENT ), true ); + $transient = get_transient( self::TRANSIENT ); + $this->assertIsString( $transient ); + $cached = json_decode( $transient, true ); + $this->assertIsArray( $cached ); // The aggregate counts are refreshed from the request. $this->assertSame( 6, $cached['good'] ); @@ -156,7 +159,13 @@ public function test_storing_counts_without_cached_results(): void { $this->assertTrue( $response['success'] ); - $cached = json_decode( get_transient( self::TRANSIENT ), true ); + $transient = get_transient( self::TRANSIENT ); + $this->assertIsString( $transient ); + $cached = json_decode( $transient, true ); + $this->assertIsArray( $cached ); + $this->assertArrayHasKey( 'good', $cached ); + $this->assertArrayHasKey( 'recommended', $cached ); + $this->assertArrayHasKey( 'critical', $cached ); $this->assertSame( 3, $cached['good'] ); $this->assertSame( 1, $cached['recommended'] ); @@ -182,7 +191,9 @@ public function test_invalid_counts_return_error_and_leave_cache_untouched(): vo $this->assertFalse( $response['success'] ); - $cached = json_decode( get_transient( self::TRANSIENT ), true ); + $transient = get_transient( self::TRANSIENT ); + $this->assertIsString( $transient ); + $cached = json_decode( $transient, true ); $this->assertSame( $existing, $cached, 'The cached result should be untouched.' ); } From 3ca14528602e914396dc6fe29f77abec0999f3e7 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 16 Jun 2026 12:11:00 +0200 Subject: [PATCH 11/40] Site Health: Cache the full results in a dedicated, non-autoloaded transient. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following review feedback, store only the aggregate counts in the autoloaded `health-check-site-status-result` transient and cache the full per-test results separately in `health-check-site-status-detail`, which has an expiration so the larger payload is not autoloaded on every request. The Site Health screen submits the full results — including the asynchronous tests that require JavaScript to run — and they are sanitized and cached as the authoritative detailed results. The scheduled check refreshes only missing or stale entries so it does not discard fresher results collected from the screen. Each result carries its own timestamp, and entries that have not been refreshed within a month are dropped. Adds WP_Site_Health::get_site_status_counts(), get_site_status_detail(), and merge_site_status_detail() as the canonical accessors, and updates the unit and Ajax tests accordingly. See #65232. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/js/_enqueues/admin/site-health.js | 31 ++ src/wp-admin/includes/ajax-actions.php | 52 ++- .../includes/class-wp-site-health.php | 310 +++++++++++-- tests/phpunit/tests/admin/wpSiteHealth.php | 417 ++++++++++++++---- .../wpAjaxHealthCheckSiteStatusResult.php | 255 ++++++++--- 5 files changed, 860 insertions(+), 205 deletions(-) diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index 57d5c9cbcf289..55ef5b15ecc99 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -161,6 +161,23 @@ jQuery( function( $ ) { issue.test = issue.status + count; } + /* + * Collect the full result so it can be cached server-side. These results + * include the asynchronous tests that only run in the browser. + */ + if ( 'undefined' === typeof SiteHealth.site_status.results ) { + SiteHealth.site_status.results = []; + } + + SiteHealth.site_status.results.push( { + test: issue.test, + label: issue.label, + status: issue.status, + badge: issue.badge, + description: issue.description, + actions: issue.actions + } ); + if ( 'critical' === issue.status ) { heading = sprintf( _n( '%s critical issue', '%s critical issues', count ), @@ -250,6 +267,7 @@ jQuery( function( $ ) { } if ( isStatusTab ) { + // Refresh the lightweight counts first, so a large detailed payload can't block them. $.post( ajaxurl, { @@ -259,6 +277,18 @@ jQuery( function( $ ) { } ); + // Send the full results separately, as a best-effort detailed cache update. + if ( 'undefined' !== typeof SiteHealth.site_status.results ) { + $.post( + ajaxurl, + { + 'action': 'health-check-site-status-result', + '_wpnonce': SiteHealth.nonce.site_status_result, + 'results': JSON.stringify( SiteHealth.site_status.results ) + } + ); + } + if ( 100 === val ) { $( '.site-status-all-clear' ).removeClass( 'hide' ); $( '.site-status-has-issues' ).addClass( 'hide' ); @@ -375,6 +405,7 @@ jQuery( function( $ ) { 'recommended': 0, 'critical': 0 }; + SiteHealth.site_status.results = []; } 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 ea4d76c338fd3..588110b6e058a 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5457,9 +5457,13 @@ function wp_ajax_health_check_loopback_requests() { /** * Handles site health check to update the result status via AJAX. * + * The aggregate counts and the full per-test results are sent as two independent + * requests, so a large results payload cannot prevent the lightweight counts from being + * refreshed. Each is handled on its own here, and either may be omitted. + * * @since 5.2.0 - * @since 7.1.0 The submitted counts are validated, and the full test results and - * collection timestamp cached by the scheduled check are preserved. + * @since 7.1.0 The submitted counts are validated, and the optional full per-test results + * are sanitized and cached separately as the authoritative detailed results. */ function wp_ajax_health_check_site_status_result() { check_ajax_referer( 'health-check-site-status-result' ); @@ -5468,37 +5472,37 @@ function wp_ajax_health_check_site_status_result() { wp_send_json_error(); } - $counts = isset( $_POST['counts'] ) && is_array( $_POST['counts'] ) ? wp_unslash( $_POST['counts'] ) : null; - - if ( ! is_array( $counts ) ) { - wp_send_json_error(); + if ( ! class_exists( 'WP_Site_Health' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; } - $site_status = array( - 'good' => (int) ( $counts['good'] ?? 0 ), - 'recommended' => (int) ( $counts['recommended'] ?? 0 ), - 'critical' => (int) ( $counts['critical'] ?? 0 ), - ); + $updated = false; + + // Refresh the lightweight, autoloaded aggregate counts used by the admin menu and Dashboard. + if ( isset( $_POST['counts'] ) && is_array( $_POST['counts'] ) ) { + $counts = wp_unslash( $_POST['counts'] ); + WP_Site_Health::set_site_status_counts( $counts ); + + $updated = true; + } /* - * Only the aggregate counts are refreshed here. Preserve the full test results - * and the timestamp recorded by the scheduled check so they are not discarded - * when the counts are updated from the Site Health screen. + * Cache the full per-test results as the authoritative detailed results. These include + * the asynchronous tests that require JavaScript to run. The values are sanitized in + * WP_Site_Health::update_site_status_detail(). */ - $cached = get_transient( 'health-check-site-status-result' ); - $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; - - if ( is_array( $cached ) ) { - if ( isset( $cached['results'] ) && is_array( $cached['results'] ) ) { - $site_status['results'] = $cached['results']; - } + if ( isset( $_POST['results'] ) && is_string( $_POST['results'] ) ) { + $results = json_decode( wp_unslash( $_POST['results'] ), true ); - if ( isset( $cached['timestamp'] ) ) { - $site_status['timestamp'] = (int) $cached['timestamp']; + if ( is_array( $results ) ) { + WP_Site_Health::update_site_status_detail( $results, true ); + $updated = true; } } - set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); + if ( ! $updated ) { + wp_send_json_error(); + } 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 52d9f1a3ee6c3..44f421b3d5207 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -29,6 +29,26 @@ class WP_Site_Health { private $timeout_missed_cron = null; private $timeout_late_cron = null; + /** + * Transient name for the cached aggregate Site Health status counts. + * + * This value is small and read on the admin menu and Dashboard, so it remains + * autoloaded. + * + * @since 7.1.0 + */ + const STATUS_RESULT_TRANSIENT = 'health-check-site-status-result'; + + /** + * Transient name for the cached detailed Site Health test results. + * + * This value can be large, so it is stored with an expiration to keep it from + * being autoloaded on every request. + * + * @since 7.1.0 + */ + const STATUS_DETAIL_TRANSIENT = 'health-check-site-status-detail'; + /** * WP_Site_Health constructor. * @@ -112,24 +132,7 @@ public function enqueue_scripts() { ), ); - $issue_counts = get_transient( 'health-check-site-status-result' ); - - if ( is_string( $issue_counts ) ) { - $issue_counts = json_decode( $issue_counts, true ); - - /* - * The cached result also stores the full test results and a timestamp. - * Only the aggregate counts are needed on the client, so avoid localizing - * the rest of the payload. - */ - if ( is_array( $issue_counts ) ) { - $health_check_js_variables['site_status']['issues'] = array( - 'good' => (int) ( $issue_counts['good'] ?? 0 ), - 'recommended' => (int) ( $issue_counts['recommended'] ?? 0 ), - 'critical' => (int) ( $issue_counts['critical'] ?? 0 ), - ); - } - } + $health_check_js_variables['site_status']['issues'] = self::get_site_status_counts(); if ( 'site-health' === $screen->id && ( ! isset( $_GET['tab'] ) || empty( $_GET['tab'] ) ) ) { $tests = WP_Site_Health::get_tests(); @@ -3370,9 +3373,8 @@ public function maybe_create_scheduled_event() { * Runs the scheduled event to check and update the latest site health status for the website. * * @since 5.4.0 - * @since 7.1.0 The cached result also stores the full test results under `results` - * and a `timestamp` indicating when they were collected, in addition - * to the aggregate `good`, `recommended`, and `critical` counts. + * @since 7.1.0 The full test results are also cached in a separate detailed + * transient via WP_Site_Health::update_site_status_detail(). */ public function wp_cron_scheduled_check() { // Bootstrap wp-admin, as WP_Cron doesn't do this for us. @@ -3415,7 +3417,7 @@ public function wp_cron_scheduled_check() { } } - foreach ( $tests['async'] as $test ) { + foreach ( $tests['async'] as $test_name => $test ) { if ( ! empty( $test['skip_cron'] ) ) { continue; } @@ -3459,7 +3461,9 @@ public function wp_cron_scheduled_check() { if ( is_array( $result ) ) { $results[] = $result; } else { + // Include the test identifier so the result is counted and cached consistently. $results[] = array( + 'test' => $test_name, 'status' => 'recommended', 'label' => __( 'A test is unavailable' ), ); @@ -3468,28 +3472,272 @@ public function wp_cron_scheduled_check() { } foreach ( $results as $result ) { - if ( ! is_array( $result ) || ! isset( $result['status'] ) ) { + if ( ! is_array( $result ) ) { continue; } - if ( 'critical' === $result['status'] ) { + $status = isset( $result['status'] ) ? $result['status'] : ''; + + if ( 'critical' === $status ) { ++$site_status['critical']; - } elseif ( 'recommended' === $result['status'] ) { + } elseif ( 'recommended' === $status ) { ++$site_status['recommended']; } else { ++$site_status['good']; } } + self::set_site_status_counts( $site_status ); + /* - * Cache the full results alongside the aggregate counts so consumers can read - * the detailed Site Health status without re-running the tests, and record when - * the results were collected so their freshness can be evaluated. + * Cache the full results separately, keyed by test, so consumers can read the + * detailed Site Health status without re-running the tests. + * + * The scheduled check is not authoritative: it refreshes only missing or stale + * entries so it does not discard fresher results collected from the Site Health + * screen, which also include the asynchronous tests that require JavaScript to run. */ - $site_status['results'] = $results; - $site_status['timestamp'] = time(); + self::update_site_status_detail( $results, false ); + } + + /** + * Returns the cached aggregate Site Health status counts. + * + * @since 7.1.0 + * + * @return array{good: int, recommended: int, critical: int} Aggregate counts. Each + * value is `0` when no + * result has been cached. + */ + public static function get_site_status_counts(): array { + $counts = array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ); + + $cached = get_transient( self::STATUS_RESULT_TRANSIENT ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + if ( ! is_array( $cached ) ) { + return $counts; + } + + $counts['good'] = (int) ( $cached['good'] ?? 0 ); + $counts['recommended'] = (int) ( $cached['recommended'] ?? 0 ); + $counts['critical'] = (int) ( $cached['critical'] ?? 0 ); + + return $counts; + } + + /** + * Caches the aggregate Site Health status counts. + * + * @since 7.1.0 + * + * @param array $counts Aggregate counts. + */ + public static function set_site_status_counts( array $counts ): void { + set_transient( + self::STATUS_RESULT_TRANSIENT, + wp_json_encode( + array( + 'good' => (int) ( $counts['good'] ?? 0 ), + 'recommended' => (int) ( $counts['recommended'] ?? 0 ), + 'critical' => (int) ( $counts['critical'] ?? 0 ), + ) + ) + ); + } + + /** + * Returns the cached detailed Site Health test results. + * + * The `counts` are derived from the cached `results`, so the two are always + * internally consistent. They may differ from WP_Site_Health::get_site_status_counts(), + * which reflects the latest run for the admin menu and Dashboard. Consumers that need a + * consistent view of counts and detailed results should read both from here. + * + * @since 7.1.0 + * + * @return array{ + * results: list>, + * counts: array{good: int, recommended: int, critical: int}, + * timestamp: int, + * } The cached results as a list, aggregate counts derived from those same results, and + * the time of the most recent update. `results` is empty, all counts are `0`, and + * `timestamp` is `0` when none are cached. + */ + public static function get_site_status_detail(): array { + $detail = array( + 'results' => array(), + 'counts' => array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + 'timestamp' => 0, + ); + + $cached = get_transient( self::STATUS_DETAIL_TRANSIENT ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + if ( is_array( $cached ) && isset( $cached['results'] ) && is_array( $cached['results'] ) ) { + $detail['results'] = array_values( $cached['results'] ); + $detail['counts'] = self::count_site_status_results( $detail['results'] ); + $detail['timestamp'] = isset( $cached['timestamp'] ) ? (int) $cached['timestamp'] : 0; + } + + return $detail; + } + + /** + * Updates the detailed Site Health results cache. + * + * Results are normalized and sanitized, then stored keyed by test name with a + * per-result timestamp. Entries that have not been refreshed within a month are + * dropped, for example when the plugin that registered a test has been deactivated. + * + * @since 7.1.0 + * + * @param array $results List of raw Site Health test result arrays. + * @param bool $authoritative Whether to overwrite existing cached entries for the + * same test. The Site Health screen passes `true` because + * its results include the JavaScript-only asynchronous + * tests. The scheduled check passes `false` so it refreshes + * only missing or stale entries without discarding fresher + * results collected from the screen. + */ + public static function update_site_status_detail( array $results, bool $authoritative ): void { + $now = time(); + + $cached = get_transient( self::STATUS_DETAIL_TRANSIENT ); + $cached = is_string( $cached ) ? json_decode( $cached, true ) : null; + + $stored = ( is_array( $cached ) && isset( $cached['results'] ) && is_array( $cached['results'] ) ) + ? $cached['results'] + : array(); + + foreach ( $results as $result ) { + $sanitized = self::sanitize_site_status_result( $result ); + + if ( null === $sanitized ) { + continue; + } + + $test = $sanitized['test']; + + /* + * When not authoritative, keep a recent existing entry rather than overwriting + * it. This preserves results collected from the Site Health screen (including + * the asynchronous tests) when the scheduled check runs afterwards. + */ + if ( ! $authoritative + && isset( $stored[ $test ]['timestamp'] ) + && ( $now - (int) $stored[ $test ]['timestamp'] ) < WEEK_IN_SECONDS + ) { + continue; + } + + $sanitized['timestamp'] = $now; + $stored[ $test ] = $sanitized; + } + + // Drop results that have not been refreshed within the last month. + foreach ( $stored as $test => $result ) { + if ( ! isset( $result['timestamp'] ) || ( $now - (int) $result['timestamp'] ) > MONTH_IN_SECONDS ) { + unset( $stored[ $test ] ); + } + } + + if ( empty( $stored ) ) { + delete_transient( self::STATUS_DETAIL_TRANSIENT ); + return; + } + + set_transient( + self::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => $stored, + // A counts snapshot derived from the same results, kept for self-describing cache reads. + 'counts' => self::count_site_status_results( array_values( $stored ) ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + } + + /** + * Counts a set of Site Health results by status. + * + * @since 7.1.0 + * + * @param array> $results List of result arrays. + * @return array{good: int, recommended: int, critical: int} Aggregate counts. Any + * unrecognized status is + * counted as `good`. + */ + private static function count_site_status_results( array $results ): array { + $counts = array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ); + + foreach ( $results as $result ) { + $status = isset( $result['status'] ) ? $result['status'] : ''; + + if ( 'critical' === $status ) { + ++$counts['critical']; + } elseif ( 'recommended' === $status ) { + ++$counts['recommended']; + } else { + ++$counts['good']; + } + } + + return $counts; + } + + /** + * Normalizes and sanitizes a single Site Health test result for caching. + * + * @since 7.1.0 + * + * @param mixed $result Raw test result data. + * @return array|null The sanitized result, or null when required fields are missing + * or the status is not recognized. + */ + private static function sanitize_site_status_result( $result ): ?array { + if ( ! is_array( $result ) ) { + return null; + } + + $test = isset( $result['test'] ) ? sanitize_text_field( (string) $result['test'] ) : ''; + $status = isset( $result['status'] ) ? sanitize_key( (string) $result['status'] ) : ''; + + if ( '' === $test || ! in_array( $status, array( 'good', 'recommended', 'critical' ), true ) ) { + return null; + } + + $sanitized = array( + 'test' => $test, + 'label' => isset( $result['label'] ) ? sanitize_text_field( (string) $result['label'] ) : '', + 'status' => $status, + 'description' => isset( $result['description'] ) ? wp_kses_post( (string) $result['description'] ) : '', + 'actions' => isset( $result['actions'] ) ? wp_kses_post( (string) $result['actions'] ) : '', + ); + + if ( isset( $result['badge'] ) && is_array( $result['badge'] ) ) { + $sanitized['badge'] = array( + 'label' => isset( $result['badge']['label'] ) ? sanitize_text_field( (string) $result['badge']['label'] ) : '', + 'color' => isset( $result['badge']['color'] ) ? sanitize_key( (string) $result['badge']['color'] ) : '', + ); + } - set_transient( 'health-check-site-status-result', wp_json_encode( $site_status ) ); + return $sanitized; } /** diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index f17edb3b1b7d7..1f1cccf1df288 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -709,69 +709,100 @@ public function test_get_test_opcode_cache_result_by_environment() { } /** - * Ensures the scheduled check caches the full results and a timestamp. + * Registers a controlled set of direct Site Health tests via `site_status_tests`. * - * The cached `health-check-site-status-result` transient must contain the - * aggregate counts, the complete (unreduced) test results, and the time the - * results were collected, so consumers can read the detailed Site Health - * status without re-running the tests. + * @param array $direct Map of result arrays to return, keyed by test name. + * @return Closure The filter callback, so the caller can remove it. + */ + private function use_fake_site_status_tests( array $direct ): Closure { + $tests = array( + 'direct' => array(), + 'async' => array(), + ); + + foreach ( $direct as $name => $result ) { + $tests['direct'][ $name ] = array( + 'label' => $name, + 'test' => static function () use ( $result ) { + return $result; + }, + ); + } + + $filter = static function () use ( $tests ) { + return $tests; + }; + + add_filter( 'site_status_tests', $filter ); + + return $filter; + } + + /** + * Seeds the detailed Site Health results cache. + * + * @param array $results Map of result arrays keyed by test name. + * @param int $time Timestamp to store for the cache and each result. + */ + private function seed_site_status_detail( array $results, int $time ): void { + foreach ( $results as $test => $result ) { + $results[ $test ]['timestamp'] = $time; + } + + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => $results, + 'timestamp' => $time, + ) + ), + MONTH_IN_SECONDS + ); + } + + /** + * The scheduled check stores only the aggregate counts in the autoloaded transient + * and caches the full results separately. * * @ticket 65232 * * @covers ::wp_cron_scheduled_check + * @covers ::get_site_status_detail + * @covers ::set_site_status_counts + * @covers ::update_site_status_detail */ - public function test_wp_cron_scheduled_check_caches_full_results(): void { - $tests = array( - 'direct' => array( + public function test_scheduled_check_stores_counts_and_detail_separately(): void { + $filter = $this->use_fake_site_status_tests( + array( 'fake_critical' => array( - 'label' => 'Fake critical', - 'test' => static function () { - return array( - 'label' => 'Critical label', - 'status' => 'critical', - 'badge' => array( - 'label' => 'Security', - 'color' => 'red', - ), - 'description' => '

Critical description.

', - 'actions' => '', - 'test' => 'fake_critical', - ); - }, + 'test' => 'fake_critical', + 'label' => 'Critical label', + 'status' => 'critical', + 'badge' => array( + 'label' => 'Security', + 'color' => 'red', + ), + 'description' => '

Critical description.

', + 'actions' => '', ), 'fake_recommended' => array( - 'label' => 'Fake recommended', - 'test' => static function () { - return array( - 'label' => 'Recommended label', - 'status' => 'recommended', - 'description' => '

Recommended description.

', - 'test' => 'fake_recommended', - ); - }, + 'test' => 'fake_recommended', + 'label' => 'Recommended label', + 'status' => 'recommended', + 'description' => '

Recommended description.

', ), 'fake_good' => array( - 'label' => 'Fake good', - 'test' => static function () { - return array( - 'label' => 'Good label', - 'status' => 'good', - 'description' => '

Good description.

', - 'test' => 'fake_good', - ); - }, + 'test' => 'fake_good', + 'label' => 'Good label', + 'status' => 'good', + 'description' => '

Good description.

', ), - ), - 'async' => array(), + ) ); - $filter = static function () use ( $tests ) { - return $tests; - }; - - add_filter( 'site_status_tests', $filter ); - - delete_transient( 'health-check-site-status-result' ); + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); $before = time(); $this->instance->wp_cron_scheduled_check(); @@ -779,52 +810,276 @@ public function test_wp_cron_scheduled_check_caches_full_results(): void { remove_filter( 'site_status_tests', $filter ); - $transient = get_transient( 'health-check-site-status-result' ); + // The counts transient holds only the aggregate counts, so it stays small enough to autoload. + $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); $this->assertIsString( $transient ); - $cached = json_decode( $transient, true ); - - $this->assertIsArray( $cached, 'The cached result should decode to an array.' ); - $this->assertArrayHasKey( 'good', $cached ); - $this->assertArrayHasKey( 'recommended', $cached ); - $this->assertArrayHasKey( 'critical', $cached ); - - // Aggregate counts are preserved at the top level. - $this->assertSame( 1, $cached['good'], 'There should be one good result.' ); - $this->assertSame( 1, $cached['recommended'], 'There should be one recommended result.' ); - $this->assertSame( 1, $cached['critical'], 'There should be one critical result.' ); + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ), + json_decode( $transient, true ), + 'The counts transient should contain only the aggregate counts.' + ); - // The full results are cached, including the passing (good) result. - $this->assertArrayHasKey( 'results', $cached, 'The full results should be cached.' ); - $this->assertIsArray( $cached['results'] ); - $this->assertCount( 3, $cached['results'], 'All test results should be cached, not just actionable ones.' ); + // The detailed cache holds every result, including the passing one. + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertArrayHasKey( 'results', $detail ); + $this->assertCount( 3, $detail['results'], 'All results should be cached, not just actionable ones.' ); $results_by_test = array(); - foreach ( $cached['results'] as $result ) { - $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'test', $result ); - $this->assertIsString( $result['test'] ); + foreach ( $detail['results'] as $result ) { $results_by_test[ $result['test'] ] = $result; } $this->assertSame( array( 'fake_critical', 'fake_recommended', 'fake_good' ), - array_keys( $results_by_test ), - 'Every test result should be cached.' + array_keys( $results_by_test ) ); - // The complete result is stored as produced, without stripping HTML. - $this->assertSame( 'critical', $results_by_test['fake_critical']['status'] ); + // Safe HTML in the description is preserved. $this->assertSame( '

Critical description.

', - $results_by_test['fake_critical']['description'], - 'The full result should be cached without reducing or stripping it.' + $results_by_test['fake_critical']['description'] + ); + $this->assertSame( + array( + 'label' => 'Security', + 'color' => 'red', + ), + $results_by_test['fake_critical']['badge'] + ); + + // Each result carries its own collection timestamp. + $this->assertGreaterThanOrEqual( $before, $results_by_test['fake_critical']['timestamp'] ); + $this->assertLessThanOrEqual( $after, $results_by_test['fake_critical']['timestamp'] ); + + // The detail cache exposes counts derived from its own results. + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 1, + 'critical' => 1, + ), + $detail['counts'], + 'Detail counts should be derived from the cached detail results.' ); - $this->assertArrayHasKey( 'badge', $results_by_test['fake_critical'], 'All result fields should be cached.' ); + } + + /** + * An asynchronous test that is unavailable during the scheduled check is cached under + * its own identifier, so the counts and the detailed results stay consistent. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + */ + public function test_scheduled_check_caches_unavailable_async_test_with_identifier(): void { + // Force the asynchronous remote request to fail so the fallback path runs. + $http = static function () { + return new WP_Error( 'unavailable', 'Service unavailable.' ); + }; + add_filter( 'pre_http_request', $http ); + + $filter = static function () { + return array( + 'direct' => array(), + 'async' => array( + 'my_async' => array( + 'label' => 'My async test', + 'test' => 'https://example.invalid/site-health', + 'has_rest' => true, + ), + ), + ); + }; + add_filter( 'site_status_tests', $filter ); + + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $this->instance->wp_cron_scheduled_check(); + + remove_filter( 'site_status_tests', $filter ); + remove_filter( 'pre_http_request', $http ); + + $detail = WP_Site_Health::get_site_status_detail(); + $results_by_id = array(); + foreach ( $detail['results'] as $result ) { + $results_by_id[ $result['test'] ] = $result; + } - // A timestamp records when the results were collected. - $this->assertArrayHasKey( 'timestamp', $cached, 'A collection timestamp should be cached.' ); - $this->assertIsInt( $cached['timestamp'] ); - $this->assertGreaterThanOrEqual( $before, $cached['timestamp'] ); - $this->assertLessThanOrEqual( $after, $cached['timestamp'] ); + $this->assertArrayHasKey( 'my_async', $results_by_id, 'The unavailable async test should be cached under its identifier.' ); + $this->assertSame( 'recommended', $results_by_id['my_async']['status'] ); + $this->assertSame( 1, $detail['counts']['recommended'], 'The unavailable async test should also be reflected in the detail counts.' ); + } + + /** + * The scheduled check does not overwrite recently cached results, preserving the + * authoritative results collected from the Site Health screen (including async tests). + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail + */ + public function test_scheduled_check_preserves_fresh_results(): void { + $this->seed_site_status_detail( + array( + 'fake_async' => array( + 'test' => 'fake_async', + 'label' => 'Async', + 'status' => 'good', + 'description' => '

From the browser.

', + ), + ), + time() + ); + + $filter = $this->use_fake_site_status_tests( + array( + 'fake_async' => array( + 'test' => 'fake_async', + 'label' => 'Async', + 'status' => 'critical', + 'description' => '

From cron.

', + ), + ) + ); + + $this->instance->wp_cron_scheduled_check(); + + remove_filter( 'site_status_tests', $filter ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertCount( 1, $detail['results'] ); + $this->assertSame( 'good', $detail['results'][0]['status'], 'The fresh browser result should be preserved.' ); + $this->assertSame( '

From the browser.

', $detail['results'][0]['description'] ); + } + + /** + * The scheduled check refreshes results that are older than the cron cadence. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail + */ + public function test_scheduled_check_refreshes_stale_results(): void { + $this->seed_site_status_detail( + array( + 'fake_test' => array( + 'test' => 'fake_test', + 'label' => 'Test', + 'status' => 'good', + 'description' => '

Stale.

', + ), + ), + time() - 2 * WEEK_IN_SECONDS + ); + + $filter = $this->use_fake_site_status_tests( + array( + 'fake_test' => array( + 'test' => 'fake_test', + 'label' => 'Test', + 'status' => 'critical', + 'description' => '

Refreshed by cron.

', + ), + ) + ); + + $this->instance->wp_cron_scheduled_check(); + + remove_filter( 'site_status_tests', $filter ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertCount( 1, $detail['results'] ); + $this->assertSame( 'critical', $detail['results'][0]['status'], 'A stale result should be refreshed by cron.' ); + $this->assertSame( '

Refreshed by cron.

', $detail['results'][0]['description'] ); + } + + /** + * Results that have not been refreshed within a month are dropped. + * + * @ticket 65232 + * + * @covers ::update_site_status_detail + */ + public function test_update_site_status_detail_drops_stale_entries(): void { + $now = time(); + + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'recent' => array( + 'test' => 'recent', + 'status' => 'good', + 'timestamp' => $now, + ), + 'old' => array( + 'test' => 'old', + 'status' => 'good', + 'timestamp' => $now - 2 * MONTH_IN_SECONDS, + ), + ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + + // Update with nothing new; the stale-entry pruning still runs. + WP_Site_Health::update_site_status_detail( array(), true ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertCount( 1, $detail['results'] ); + $this->assertSame( 'recent', $detail['results'][0]['test'] ); + } + + /** + * The counts accessor returns zeroed counts when nothing is cached. + * + * @ticket 65232 + * + * @covers ::get_site_status_counts + */ + public function test_get_site_status_counts_defaults_when_uncached(): void { + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + + $this->assertSame( + array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + WP_Site_Health::get_site_status_counts() + ); + } + + /** + * The detail accessor returns an empty array when nothing is cached. + * + * @ticket 65232 + * + * @covers ::get_site_status_detail + */ + public function test_get_site_status_detail_empty_when_uncached(): void { + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + $this->assertSame( + array( + 'results' => array(), + 'counts' => array( + 'good' => 0, + 'recommended' => 0, + 'critical' => 0, + ), + 'timestamp' => 0, + ), + WP_Site_Health::get_site_status_detail() + ); } } diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index 4fc24ed5216a8..105469efadee6 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -4,9 +4,10 @@ * Admin Ajax functions to be tested. */ require_once ABSPATH . 'wp-admin/includes/ajax-actions.php'; +require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; /** - * Tests the Ajax handler that persists Site Health check result counts. + * Tests the Ajax handler that persists Site Health check results. * * @package WordPress * @subpackage UnitTests @@ -20,9 +21,9 @@ class Tests_Ajax_wpAjaxHealthCheckSiteStatusResult extends WP_Ajax_UnitTestCase { /** - * The Site Health result transient and nonce action name. + * The nonce action used by the Site Health result Ajax request. */ - const TRANSIENT = 'health-check-site-status-result'; + const ACTION = 'health-check-site-status-result'; /** * User ID granted super admin privileges during a multisite test. @@ -36,7 +37,7 @@ public function set_up(): void { parent::set_up(); // This Ajax action is not part of the core actions registered by the base test case. - add_action( 'wp_ajax_' . self::TRANSIENT, 'wp_ajax_health_check_site_status_result', 1 ); + add_action( 'wp_ajax_' . self::ACTION, 'wp_ajax_health_check_site_status_result', 1 ); } /** @@ -72,7 +73,7 @@ private function dispatch_result_request(): array { $this->_last_response = ''; try { - $this->_handleAjax( self::TRANSIENT ); + $this->_handleAjax( self::ACTION ); } catch ( WPAjaxDieContinueException $e ) { unset( $e ); } @@ -83,75 +84,178 @@ private function dispatch_result_request(): array { } /** - * The browser only refreshes the counts, so the full results and timestamp - * cached by the scheduled check must be preserved. + * Returns the detailed cached results keyed by test name. + * + * @return array + */ + private function get_detail_results_by_test(): array { + $detail = WP_Site_Health::get_site_status_detail(); + + if ( empty( $detail['results'] ) ) { + return array(); + } + + $by_test = array(); + foreach ( $detail['results'] as $result ) { + $by_test[ $result['test'] ] = $result; + } + + return $by_test; + } + + /** + * The aggregate counts are cached on their own, while the full results submitted by + * the Site Health screen are sanitized and cached separately. * * @ticket 65232 */ - public function test_refreshing_counts_preserves_cached_results_and_timestamp(): void { - $timestamp = 1715714399; - $results = array( - array( - 'test' => 'fake_critical', - 'label' => 'Critical label', - 'status' => 'critical', - 'description' => '

Critical description.

', - ), - ); + public function test_posting_counts_and_results_caches_them_separately(): void { + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); - set_transient( - self::TRANSIENT, + $this->set_user_with_site_health_capability(); + $_POST['_wpnonce'] = wp_create_nonce( self::ACTION ); + $_POST['counts'] = array( + 'good' => 5, + 'recommended' => 1, + 'critical' => 2, + ); + $_POST['results'] = wp_slash( wp_json_encode( array( - 'good' => 5, - 'recommended' => 0, - 'critical' => 1, - 'results' => $results, - 'timestamp' => $timestamp, + array( + 'test' => 'a', + 'label' => 'A', + 'status' => 'critical', + 'description' => '

Bad

', + 'actions' => '', + ), + array( + 'test' => 'b', + 'label' => 'B', + 'status' => 'recommended', + 'description' => '

B

', + ), + array( + 'test' => 'c', + 'label' => 'C', + 'status' => 'good', + 'description' => '

C

', + ), + array( + // An unrecognized status is dropped. + 'test' => 'd', + 'label' => 'D', + 'status' => 'bogus', + 'description' => '

D

', + ), ) ) ); + $response = $this->dispatch_result_request(); + + $this->assertTrue( $response['success'] ); + + // The counts transient holds only the aggregate counts. + $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $transient ); + $this->assertSame( + array( + 'good' => 5, + 'recommended' => 1, + 'critical' => 2, + ), + json_decode( $transient, true ) + ); + + // Only results with a recognized status are cached. + $results = $this->get_detail_results_by_test(); + $this->assertSame( array( 'a', 'b', 'c' ), array_keys( $results ) ); + + // Disallowed HTML is stripped from the cached description. + $this->assertStringNotContainsString( '', - 'actions' => '', + 'a' => array( + 'status' => 'critical', ), - array( - 'test' => 'b', - 'label' => 'B', - 'status' => 'recommended', - 'description' => '

B

', + 'b' => array( + 'status' => 'recommended', ), - array( - 'test' => 'c', - 'label' => 'C', - 'status' => 'good', - 'description' => '

C

', + 'c' => array( + 'status' => 'good', ), - array( - // An unrecognized status is dropped. - 'test' => 'd', - 'label' => 'D', - 'status' => 'bogus', - 'description' => '

D

', + 'd' => array( + // An unrecognized status is rejected. + 'status' => 'bogus', ), ) ) ); $response = $this->dispatch_result_request(); + $this->assertFalse( $response['success'] ); + $_POST['results'] = wp_slash( + wp_json_encode( + array( + 'a' => array( + 'status' => 'critical', + ), + 'b' => array( + 'status' => 'recommended', + ), + 'c' => array( + 'status' => 'good', + ), + ) + ) + ); + + $response = $this->dispatch_result_request(); $this->assertTrue( $response['success'] ); // The counts transient holds only the aggregate counts. @@ -154,8 +159,8 @@ public function test_posting_counts_and_results_caches_them_separately(): void { $this->assertSame( array( 'a', 'b', 'c' ), array_keys( $results ) ); // Only locale-independent fields are cached: the test name, status, and timestamp. - $this->assertSame( - array( 'test', 'status', 'timestamp' ), + $this->assertSameSets( + array( 'status', 'timestamp' ), array_keys( $results['a'] ), 'No translated or HTML fields should be cached.' ); @@ -190,9 +195,7 @@ public function test_posting_results_only_updates_detail_without_counts(): void $_POST['results'] = wp_slash( wp_json_encode( array( - array( - 'test' => 'a', - 'label' => 'A', + 'a' => array( 'status' => 'critical', ), ) @@ -224,11 +227,11 @@ public function test_posting_counts_only_leaves_detail_intact(): void { array( 'results' => array( 'seeded' => array( - 'test' => 'seeded', 'status' => 'good', 'timestamp' => $now, ), ), + 'counts' => array(), 'timestamp' => $now, ) ), From 0718c3f814bb9c3e05ad6e474d6651f24120041f Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 08:06:10 +0200 Subject: [PATCH 29/40] Site Health: Compute detailed status counts at runtime --- .../includes/class-wp-site-health.php | 77 +++++++++++-------- tests/phpunit/tests/admin/wpSiteHealth.php | 37 ++++++++- .../wpAjaxHealthCheckSiteStatusResult.php | 1 - 3 files changed, 77 insertions(+), 38 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 35230cd527399..4efe60885c266 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -19,7 +19,11 @@ * } * @phpstan-type Stored_Test_Results array{ * results: array, - * counts: Test_Status_Counts, // TODO: This is redundant. + * timestamp: non-negative-int, + * } + * @phpstan-type Site_Status_Detail array{ + * results: array, + * counts: Test_Status_Counts, * timestamp: non-negative-int, * } */ @@ -47,6 +51,24 @@ class WP_Site_Health { private const VALID_STATUS_VALUES = array( 'good', 'recommended', 'critical' ); + private const STATUS_COUNTS_SCHEMA = array( + 'type' => 'object', + 'properties' => array( + 'good' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'recommended' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'critical' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + ), + ); + private const STORED_STATUS_SCHEMA = array( 'type' => 'object', 'properties' => array( @@ -68,30 +90,12 @@ class WP_Site_Health { 'additionalProperties' => false, ), ), - // TODO: This counts is redundant. Why store it? It can be computed at runtime from the results. - 'counts' => array( - 'type' => 'object', - 'properties' => array( - 'good' => array( - 'type' => 'integer', - 'minimum' => 0, - ), - 'recommended' => array( - 'type' => 'integer', - 'minimum' => 0, - ), - 'critical' => array( - 'type' => 'integer', - 'minimum' => 0, - ), - ), - ), 'timestamp' => array( 'type' => 'integer', 'minimum' => 1, ), ), - 'required' => array( 'results', 'counts' ), + 'required' => array( 'results' ), ); /** @@ -3588,7 +3592,16 @@ public function wp_cron_scheduled_check() { * @return Test_Status_Counts Aggregate counts. Each value is `0` when no result has been cached. */ public static function get_site_status_counts(): array { - return self::read_status_cache( self::STATUS_RESULT_TRANSIENT )['counts']; + $counts = get_transient( self::STATUS_RESULT_TRANSIENT ); + if ( is_string( $counts ) ) { + $counts = json_decode( $counts, true ); + } + + if ( ! is_array( $counts ) || is_wp_error( rest_validate_value_from_schema( $counts, self::STATUS_COUNTS_SCHEMA ) ) ) { + $counts = array(); + } + + return self::normalize_status_counts( $counts ); } /** @@ -3600,7 +3613,7 @@ public static function get_site_status_counts(): array { * @return true|WP_Error True when the counts were saved, or WP_Error on failure. */ public static function set_site_status_counts( array $counts ) { - $validity = rest_validate_value_from_schema( $counts, self::STORED_STATUS_SCHEMA['properties']['counts'] ); + $validity = rest_validate_value_from_schema( $counts, self::STATUS_COUNTS_SCHEMA ); if ( is_wp_error( $validity ) ) { return $validity; } @@ -3631,10 +3644,16 @@ public static function set_site_status_counts( array $counts ) { * @return array The cached results as a list, aggregate counts derived from those same results, and * the time of the most recent update. `results` is empty, all counts are `0`, and * `timestamp` is `0` when none are cached. - * @phpstan-return Stored_Test_Results + * @phpstan-return Site_Status_Detail */ public static function get_site_status_detail(): array { - return self::read_status_cache( self::STATUS_DETAIL_TRANSIENT ); + $cached = self::read_status_cache( self::STATUS_DETAIL_TRANSIENT ); + + return array( + 'results' => $cached['results'], + 'counts' => self::count_site_status_results( array_values( $cached['results'] ) ), + 'timestamp' => $cached['timestamp'], + ); } /** @@ -3707,8 +3726,6 @@ public static function update_site_status_detail( array $results, bool $includes $stored_value = wp_json_encode( array( 'results' => $cached['results'], - // A counts snapshot derived from the same results, kept for self-describing cache reads. - 'counts' => self::count_site_status_results( array_values( $cached['results'] ) ), 'timestamp' => $now, ) ); @@ -3746,18 +3763,10 @@ private static function read_status_cache( string $transient ): array { if ( true !== $validity ) { $cached = array( 'results' => array(), - 'counts' => array(), 'timestamp' => 0, ); } - foreach ( array_keys( self::STORED_STATUS_SCHEMA['properties']['counts']['properties'] ) as $key ) { - /** @var array{ counts: array, ... } $cached */ - if ( ! isset( $cached['counts'][ $key ] ) ) { - $cached['counts'][ $key ] = 0; - } - } - /** @var Stored_Test_Results $cached */ return $cached; } diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index aa514ec5d32cc..639fabbaa301e 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -811,18 +811,31 @@ public function test_scheduled_check_stores_counts_and_detail_separately(): void remove_filter( 'site_status_tests', $filter ); // The counts transient holds only the aggregate counts, so it stays small enough to autoload. - $transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); - $this->assertIsString( $transient ); + $counts_transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $this->assertIsString( $counts_transient ); + $stored_counts = json_decode( $counts_transient, true ); + $this->assertIsArray( $stored_counts ); $this->assertSame( array( 'good' => 1, 'recommended' => 1, 'critical' => 1, ), - json_decode( $transient, true ), + $stored_counts, 'The counts transient should contain only the aggregate counts.' ); + // The detail transient stores only results and a timestamp; counts are derived when it is read. + $detail_transient = get_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + $this->assertIsString( $detail_transient ); + $stored_detail = json_decode( $detail_transient, true ); + $this->assertIsArray( $stored_detail ); + $this->assertSameSets( + array( 'results', 'timestamp' ), + array_keys( $stored_detail ), + 'The detail transient should contain only the results and timestamp.' + ); + // The detailed cache holds every result, including the passing one. $detail = WP_Site_Health::get_site_status_detail(); $this->assertArrayHasKey( 'results', $detail ); @@ -1036,6 +1049,24 @@ public function test_get_site_status_counts_defaults_when_uncached(): void { ); } + /** + * The counts accessor returns the cached aggregate counts. + * + * @ticket 65232 + * + * @covers ::get_site_status_counts + */ + public function test_get_site_status_counts_returns_cached_counts(): void { + $counts = array( + 'good' => 3, + 'recommended' => 2, + 'critical' => 1, + ); + set_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT, wp_json_encode( $counts ) ); + + $this->assertSame( $counts, WP_Site_Health::get_site_status_counts() ); + } + /** * The detail accessor returns an empty array when nothing is cached. * diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index 7b6d881450980..b51b96d8ff483 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -231,7 +231,6 @@ public function test_posting_counts_only_leaves_detail_intact(): void { 'timestamp' => $now, ), ), - 'counts' => array(), 'timestamp' => $now, ) ), From b718838c6e9dc6b39fb9c040ad9cc715d5ac7d31 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 08:30:09 +0200 Subject: [PATCH 30/40] Site Health: Specialize the detailed status cache reader --- src/wp-admin/includes/class-wp-site-health.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 4efe60885c266..822b10dba8285 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3647,7 +3647,7 @@ public static function set_site_status_counts( array $counts ) { * @phpstan-return Site_Status_Detail */ public static function get_site_status_detail(): array { - $cached = self::read_status_cache( self::STATUS_DETAIL_TRANSIENT ); + $cached = self::read_site_status_detail_cache(); return array( 'results' => $cached['results'], @@ -3678,7 +3678,7 @@ public static function get_site_status_detail(): array { public static function update_site_status_detail( array $results, bool $includes_async ) { $now = time(); - $cached = self::read_status_cache( self::STATUS_DETAIL_TRANSIENT ); + $cached = self::read_site_status_detail_cache(); foreach ( $results as $test => $result ) { $test = sanitize_text_field( $test ); @@ -3740,17 +3740,15 @@ public static function update_site_status_detail( array $results, bool $includes } /** - * Reads and decodes a cached Site Health transient. + * Reads and decodes the cached detailed Site Health test results. * * @since 7.1.0 - * @todo Why is the $transient a parameter for this function? It can read the constant directly. * - * @param string $transient Transient name. * @return array The stored status. * @phpstan-return Stored_Test_Results */ - private static function read_status_cache( string $transient ): array { - $cached = get_transient( $transient ); + private static function read_site_status_detail_cache(): array { + $cached = get_transient( self::STATUS_DETAIL_TRANSIENT ); if ( is_string( $cached ) ) { $cached = json_decode( $cached, true ); } From f727ddb05283e5cb311d2d49357da24968d18677 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 09:22:26 +0200 Subject: [PATCH 31/40] Site Health: Fix storing test results in JavaScript --- src/js/_enqueues/admin/site-health.js | 2 +- tests/qunit/index.html | 20 ++++++++++++++++++++ tests/qunit/wp-admin/js/site-health.js | 24 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/qunit/wp-admin/js/site-health.js diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index e42d5a7c65826..b0eedee5f82e2 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -166,7 +166,7 @@ jQuery( function( $ ) { * include the asynchronous tests that only run in the browser. Labels are left * out on purpose, as they are translated and the cache is shared across locales. */ - SiteHealth.site_status[ issue.test ] = { + SiteHealth.site_status.results[ issue.test ] = { status: issue.status }; diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 9fd35f0c1ffc2..9d1f5f0621d03 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -87,9 +87,11 @@ + + @@ -100,6 +102,23 @@ + + @@ -151,6 +170,7 @@ + diff --git a/tests/qunit/wp-admin/js/site-health.js b/tests/qunit/wp-admin/js/site-health.js new file mode 100644 index 0000000000000..2e9d7a114b668 --- /dev/null +++ b/tests/qunit/wp-admin/js/site-health.js @@ -0,0 +1,24 @@ +/* global SiteHealth */ + +QUnit.module( 'Site Health', function() { + QUnit.test( 'updates state after running direct tests', function( assert ) { + assert.deepEqual( + SiteHealth.site_status.results, + { + direct_test: { + status: 'good' + } + }, + 'The test results contain locale-independent data.' + ); + assert.deepEqual( + SiteHealth.site_status.issues, + { + critical: 0, + good: 1, + recommended: 0 + }, + 'The issue counts reflect the test results.' + ); + } ); +} ); From 26799d5489c982a8879cba53770558c70ccb48c4 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 10:04:08 +0200 Subject: [PATCH 32/40] Site Health: Clarify detailed result documentation --- .../includes/class-wp-site-health.php | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 822b10dba8285..574e1097f8ec3 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3641,9 +3641,9 @@ public static function set_site_status_counts( array $counts ) { * @since 7.1.0 * @todo This method is now not used. * - * @return array The cached results as a list, aggregate counts derived from those same results, and - * the time of the most recent update. `results` is empty, all counts are `0`, and - * `timestamp` is `0` when none are cached. + * @return array The cached results as a map keyed by test name, aggregate counts derived + * from those same results, and the time of the most recent update. `results` + * is empty, all counts are `0`, and `timestamp` is `0` when none are cached. * @phpstan-return Site_Status_Detail */ public static function get_site_status_detail(): array { @@ -3665,14 +3665,16 @@ public static function get_site_status_detail(): array { * * @since 7.1.0 * - * @param mixed[] $results List of raw Site Health test result arrays. - * @param bool $includes_async Whether the results include the JavaScript-only - * asynchronous tests. The Site Health screen passes `true` - * because it collects those tests in the browser, so its - * entries take precedence over existing cached ones. The - * scheduled check passes `false` since it cannot run the - * asynchronous tests, so it only fills in missing or stale - * entries without discarding fresher results from the screen. + * @param array $results Map of raw Site Health test result arrays, + * keyed by test name. + * @param bool $includes_async Whether the results include the JavaScript-only + * asynchronous tests. The Site Health screen passes + * `true` because it collects those tests in the browser, + * so its entries take precedence over existing cached + * ones. The scheduled check passes `false` since it cannot + * run the asynchronous tests, so it only fills in missing + * or stale entries without discarding fresher results from + * the screen. * @return true|WP_Error True on success, WP_Error on failure. */ public static function update_site_status_detail( array $results, bool $includes_async ) { From a84ac0075fd021883e9bcee0e6cceee85a32964a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 11:18:24 +0200 Subject: [PATCH 33/40] Site Health: Validate scheduled test results --- .../includes/class-wp-site-health.php | 38 +++++------ tests/phpunit/tests/admin/wpSiteHealth.php | 67 +++++++++++++++++++ 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 574e1097f8ec3..8a2ec22ac5195 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3460,12 +3460,6 @@ public function wp_cron_scheduled_check() { $results = array(); - $site_status = array( - 'good' => 0, - 'recommended' => 0, - 'critical' => 0, - ); - // Don't run https test on development environments. if ( $this->is_development_environment() ) { unset( $tests['async']['https_status'] ); @@ -3547,23 +3541,29 @@ public function wp_cron_scheduled_check() { } } + $results_to_count = array(); + $results_to_store = array(); foreach ( $results as $result ) { + // Filters may return unexpected values, so require the fields used by both caches. if ( ! is_array( $result ) ) { continue; } - $status = $result['status'] ?? ''; - - if ( 'critical' === $status ) { - ++$site_status['critical']; - } elseif ( 'recommended' === $status ) { - ++$site_status['recommended']; - } else { - ++$site_status['good']; + $test = $result['test'] ?? null; + $status = $result['status'] ?? null; + if ( + ! is_string( $test ) + || '' === sanitize_text_field( $test ) + || ! in_array( $status, self::VALID_STATUS_VALUES, true ) + ) { + continue; } + + $results_to_count[] = array( 'status' => $status ); + $results_to_store[ $test ] = array( 'status' => $status ); } - self::set_site_status_counts( $site_status ); + self::set_site_status_counts( self::count_site_status_results( $results_to_count ) ); /* * Cache the full results separately, keyed by test, so consumers can read the @@ -3573,14 +3573,6 @@ public function wp_cron_scheduled_check() { * entries so it does not discard fresher results collected from the Site Health * screen, which also include the asynchronous tests that require JavaScript to run. */ - $results_to_store = array(); - foreach ( $results as $result ) { - $test = $result['test'] ?? null; - if ( is_string( $test ) ) { - unset( $result['test'] ); - $results_to_store[ $test ] = wp_array_slice_assoc( $result, array( 'status', 'timestamp' ) ); - } - } self::update_site_status_detail( $results_to_store, false ); } diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 639fabbaa301e..a4bcf99becfb5 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -872,6 +872,73 @@ public function test_scheduled_check_stores_counts_and_detail_separately(): void ); } + /** + * The scheduled check ignores invalid results returned by the result filter, + * allowing valid results from other tests to be counted and cached. + * + * @ticket 65232 + * + * @covers ::wp_cron_scheduled_check + */ + public function test_scheduled_check_uses_only_valid_filtered_results(): void { + $invalid_results = array( + 'non_array_result' => new WP_Error( 'invalid_result' ), + 'missing_test' => array( 'status' => 'good' ), + 'empty_test' => array( + 'test' => '', + 'status' => 'good', + ), + 'invalid_test' => array( + 'test' => array(), + 'status' => 'good', + ), + 'missing_status' => array( 'test' => 'missing_status' ), + 'invalid_status' => array( + 'test' => 'invalid_status', + 'status' => 'invalid', + ), + ); + $test_results = array(); + foreach ( array_merge( array_keys( $invalid_results ), array( 'valid_result' ) ) as $test ) { + $test_results[ $test ] = array( + 'test' => $test, + 'status' => 'good', + ); + } + + $tests_filter = $this->use_fake_site_status_tests( $test_results ); + $result_filter = static function ( $result ) use ( $invalid_results ) { + if ( is_array( $result ) && isset( $invalid_results[ $result['test'] ] ) ) { + return $invalid_results[ $result['test'] ]; + } + + return $result; + }; + add_filter( 'site_status_test_result', $result_filter ); + + delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); + delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + + try { + $this->instance->wp_cron_scheduled_check(); + } finally { + remove_filter( 'site_status_test_result', $result_filter ); + remove_filter( 'site_status_tests', $tests_filter ); + } + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array( 'valid_result' ), array_keys( $detail['results'] ) ); + $this->assertSame( + array( + 'good' => 1, + 'recommended' => 0, + 'critical' => 0, + ), + $detail['counts'] + ); + $this->assertSame( $detail['counts'], WP_Site_Health::get_site_status_counts() ); + } + /** * An asynchronous test that is unavailable during the scheduled check is cached under * its own identifier, so the counts and the detailed results stay consistent. From 9ea12a3a9955996b025358c34e96e183394a683a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 11:37:27 +0200 Subject: [PATCH 34/40] Site Health: Compare detail cache with correct transient --- src/wp-admin/includes/class-wp-site-health.php | 2 +- tests/phpunit/tests/admin/wpSiteHealth.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 8a2ec22ac5195..b59ab014afe25 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3723,7 +3723,7 @@ public static function update_site_status_detail( array $results, bool $includes 'timestamp' => $now, ) ); - if ( get_transient( self::STATUS_RESULT_TRANSIENT ) === $stored_value ) { + if ( get_transient( self::STATUS_DETAIL_TRANSIENT ) === $stored_value ) { return true; } diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index a4bcf99becfb5..3f0efc03384f5 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -804,12 +804,26 @@ public function test_scheduled_check_stores_counts_and_detail_separately(): void delete_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + $counts_reads_before = did_filter( 'pre_transient_' . WP_Site_Health::STATUS_RESULT_TRANSIENT ); + $detail_reads_before = did_filter( 'pre_transient_' . WP_Site_Health::STATUS_DETAIL_TRANSIENT ); + $before = time(); $this->instance->wp_cron_scheduled_check(); $after = time(); remove_filter( 'site_status_tests', $filter ); + $this->assertSame( + 1, + did_filter( 'pre_transient_' . WP_Site_Health::STATUS_RESULT_TRANSIENT ) - $counts_reads_before, + 'The counts transient should be read once during the scheduled check.' + ); + $this->assertSame( + 2, + did_filter( 'pre_transient_' . WP_Site_Health::STATUS_DETAIL_TRANSIENT ) - $detail_reads_before, + 'The detail transient should be read twice during the scheduled check.' + ); + // The counts transient holds only the aggregate counts, so it stays small enough to autoload. $counts_transient = get_transient( WP_Site_Health::STATUS_RESULT_TRANSIENT ); $this->assertIsString( $counts_transient ); From 832da82b11699f033984b5a56782e2157695e4af Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 11:41:06 +0200 Subject: [PATCH 35/40] Site Health: Ignore results without test identifiers --- src/js/_enqueues/admin/site-health.js | 5 ----- tests/qunit/index.html | 5 +++++ tests/qunit/wp-admin/js/site-health.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/js/_enqueues/admin/site-health.js b/src/js/_enqueues/admin/site-health.js index b0eedee5f82e2..7012f5b848f9c 100644 --- a/src/js/_enqueues/admin/site-health.js +++ b/src/js/_enqueues/admin/site-health.js @@ -156,11 +156,6 @@ jQuery( function( $ ) { count = SiteHealth.site_status.issues[ issue.status ]; - // If no test name is supplied, append a placeholder for markup references. - if ( typeof issue.test === 'undefined' ) { - issue.test = issue.status + count; - } - /* * Collect the test name and status so they can be cached server-side. These * include the asynchronous tests that only run in the browser. Labels are left diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 9d1f5f0621d03..27632d4545cea 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -112,6 +112,11 @@ label: 'A test label', status: 'good', test: 'direct_test' + }, + { + description: 'An invalid test description.', + label: 'An invalid test without an identifier', + status: 'critical' } ], results: {} diff --git a/tests/qunit/wp-admin/js/site-health.js b/tests/qunit/wp-admin/js/site-health.js index 2e9d7a114b668..8d3c17d8ec9f3 100644 --- a/tests/qunit/wp-admin/js/site-health.js +++ b/tests/qunit/wp-admin/js/site-health.js @@ -1,7 +1,7 @@ /* global SiteHealth */ QUnit.module( 'Site Health', function() { - QUnit.test( 'updates state after running direct tests', function( assert ) { + QUnit.test( 'updates state only for valid direct tests', function( assert ) { assert.deepEqual( SiteHealth.site_status.results, { From 3e5930885bd6794812247d87e5a5639213b2f4c9 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 11:45:23 +0200 Subject: [PATCH 36/40] Site Health: Clarify detail result validation --- .../includes/class-wp-site-health.php | 3 +-- tests/phpunit/tests/admin/wpSiteHealth.php | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index b59ab014afe25..d3f6dd14a3c4f 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3680,9 +3680,8 @@ public static function update_site_status_detail( array $results, bool $includes return new WP_Error( 'invalid_test' ); } - // TODO: Return as error. $validity = rest_validate_value_from_schema( $result, self::STORED_STATUS_SCHEMA['properties']['results']['additionalProperties'] ); - if ( true !== $validity ) { + if ( is_wp_error( $validity ) ) { return $validity; } /** @var Stored_Test_Result $result */ diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 3f0efc03384f5..86fe8dd74fdfc 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -887,14 +887,15 @@ public function test_scheduled_check_stores_counts_and_detail_separately(): void } /** - * The scheduled check ignores invalid results returned by the result filter, - * allowing valid results from other tests to be counted and cached. + * Only valid results are cached. The scheduled check ignores invalid results + * returned by the result filter, while direct updates return a validation error. * * @ticket 65232 * * @covers ::wp_cron_scheduled_check + * @covers ::update_site_status_detail */ - public function test_scheduled_check_uses_only_valid_filtered_results(): void { + public function test_only_valid_results_are_cached(): void { $invalid_results = array( 'non_array_result' => new WP_Error( 'invalid_result' ), 'missing_test' => array( 'status' => 'good' ), @@ -951,6 +952,18 @@ public function test_scheduled_check_uses_only_valid_filtered_results(): void { $detail['counts'] ); $this->assertSame( $detail['counts'], WP_Site_Health::get_site_status_counts() ); + + $result = WP_Site_Health::update_site_status_detail( + array( + 'invalid_status' => array( + 'status' => 'invalid', + ), + ), + true + ); + + $this->assertWPError( $result ); + $this->assertSame( array( 'valid_result' ), array_keys( WP_Site_Health::get_site_status_detail()['results'] ) ); } /** From 52ca60496d38d9eb3997e59497c92467c82cf6db Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 12:00:43 +0200 Subject: [PATCH 37/40] Site Health: Return first AJAX save error --- src/wp-admin/includes/ajax-actions.php | 2 +- tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 3eeb4cd4e313e..f70af7d7ed5e6 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5504,7 +5504,7 @@ function wp_ajax_health_check_site_status_result() { $errors = array_filter( $save_results, 'is_wp_error' ); if ( count( $errors ) > 0 ) { - wp_send_json_error( $errors ); + wp_send_json_error( reset( $errors ) ); } wp_send_json_success(); diff --git a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php index b51b96d8ff483..a64520decb187 100644 --- a/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php +++ b/tests/phpunit/tests/ajax/wpAjaxHealthCheckSiteStatusResult.php @@ -122,6 +122,7 @@ public function test_posting_counts_and_results_caches_them_separately(): void { $response = $this->dispatch_result_request(); $this->assertFalse( $response['success'] ); + $this->assertSame( 'rest_not_in_enum', $response['data'][0]['code'] ); $_POST['results'] = wp_slash( wp_json_encode( From e3a9e4ee4bb1ed1485b23d0a5f38390e2cfa36e4 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 12:16:12 +0200 Subject: [PATCH 38/40] Site Health: Require detailed status timestamps --- .../includes/class-wp-site-health.php | 14 ++--- tests/phpunit/tests/admin/wpSiteHealth.php | 61 +++++++++++++++---- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index d3f6dd14a3c4f..70d150cd9a5e1 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -86,7 +86,7 @@ class WP_Site_Health { 'minimum' => 1, ), ), - 'required' => array( 'status' ), + 'required' => array( 'status', 'timestamp' ), 'additionalProperties' => false, ), ), @@ -95,7 +95,7 @@ class WP_Site_Health { 'minimum' => 1, ), ), - 'required' => array( 'results' ), + 'required' => array( 'results', 'timestamp' ), ); /** @@ -3680,6 +3680,10 @@ public static function update_site_status_detail( array $results, bool $includes return new WP_Error( 'invalid_test' ); } + if ( is_array( $result ) ) { + $result['timestamp'] = $now; + } + $validity = rest_validate_value_from_schema( $result, self::STORED_STATUS_SCHEMA['properties']['results']['additionalProperties'] ); if ( is_wp_error( $validity ) ) { return $validity; @@ -3700,7 +3704,6 @@ public static function update_site_status_detail( array $results, bool $includes continue; } - $result['timestamp'] = $now; $cached['results'][ $test ] = $result; } @@ -3711,11 +3714,6 @@ public static function update_site_status_detail( array $results, bool $includes } } - if ( empty( $cached['results'] ) ) { - delete_transient( self::STATUS_DETAIL_TRANSIENT ); - return true; - } - $stored_value = wp_json_encode( array( 'results' => $cached['results'], diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 86fe8dd74fdfc..a9006e5d45fb8 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -1162,26 +1162,65 @@ public function test_get_site_status_counts_returns_cached_counts(): void { } /** - * The detail accessor returns an empty array when nothing is cached. + * The detail accessor distinguishes a valid empty cache from a missing or malformed cache. * * @ticket 65232 * * @covers ::get_site_status_detail + * @covers ::update_site_status_detail */ - public function test_get_site_status_detail_empty_when_uncached(): void { + public function test_get_site_status_detail_distinguishes_empty_from_missing_or_malformed_cache(): void { delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); - $this->assertSame( - array( - 'results' => array(), - 'counts' => array( - 'good' => 0, - 'recommended' => 0, - 'critical' => 0, + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertSame( 0, $detail['timestamp'] ); + + $before = time(); + $this->assertTrue( WP_Site_Health::update_site_status_detail( array(), true ) ); + $after = time(); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertGreaterThanOrEqual( $before, $detail['timestamp'] ); + $this->assertLessThanOrEqual( $after, $detail['timestamp'] ); + $this->assertIsString( get_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ) ); + + $now = time(); + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'missing_timestamp' => array( 'status' => 'good' ), + ), + 'timestamp' => $now, + ) + ), + MONTH_IN_SECONDS + ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertSame( 0, $detail['timestamp'] ); + + set_transient( + WP_Site_Health::STATUS_DETAIL_TRANSIENT, + wp_json_encode( + array( + 'results' => array( + 'missing_cache_timestamp' => array( + 'status' => 'good', + 'timestamp' => $now, + ), + ), ), - 'timestamp' => 0, ), - WP_Site_Health::get_site_status_detail() + MONTH_IN_SECONDS ); + + $detail = WP_Site_Health::get_site_status_detail(); + $this->assertSame( array(), $detail['results'] ); + $this->assertSame( 0, $detail['timestamp'] ); } } From 58edf87e7333a2512c1303a710a90e5aaa92da97 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 12:38:07 +0200 Subject: [PATCH 39/40] Site Health: Store freshest verified results --- src/wp-admin/includes/ajax-actions.php | 2 +- .../includes/class-wp-site-health.php | 50 ++++-------- tests/phpunit/tests/admin/wpSiteHealth.php | 76 ++++++------------- 3 files changed, 40 insertions(+), 88 deletions(-) diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index f70af7d7ed5e6..11e663f763b44 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -5494,7 +5494,7 @@ function wp_ajax_health_check_site_status_result() { $results = json_decode( wp_unslash( $_POST['results'] ), true ); if ( is_array( $results ) ) { - $save_results[] = WP_Site_Health::update_site_status_detail( $results, true ); + $save_results[] = WP_Site_Health::update_site_status_detail( $results ); } } diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 70d150cd9a5e1..7db471fbc3bff 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3458,7 +3458,8 @@ public function wp_cron_scheduled_check() { $tests = WP_Site_Health::get_tests(); - $results = array(); + $results = array(); + $unavailable_tests = array(); // Don't run https test on development environments. if ( $this->is_development_environment() ) { @@ -3531,8 +3532,9 @@ public function wp_cron_scheduled_check() { if ( is_array( $result ) ) { $results[] = $result; } else { - // Include the test identifier so the result is counted and cached consistently. - $results[] = array( + // Include the test identifier so the unavailable result is counted consistently. + $unavailable_tests[ $test_name ] = true; + $results[] = array( 'test' => $test_name, 'status' => 'recommended', 'label' => __( 'A test is unavailable' ), @@ -3559,8 +3561,10 @@ public function wp_cron_scheduled_check() { continue; } - $results_to_count[] = array( 'status' => $status ); - $results_to_store[ $test ] = array( 'status' => $status ); + $results_to_count[] = array( 'status' => $status ); + if ( ! isset( $unavailable_tests[ $test ] ) ) { + $results_to_store[ $test ] = array( 'status' => $status ); + } } self::set_site_status_counts( self::count_site_status_results( $results_to_count ) ); @@ -3569,11 +3573,11 @@ public function wp_cron_scheduled_check() { * Cache the full results separately, keyed by test, so consumers can read the * detailed Site Health status without re-running the tests. * - * The scheduled check is not authoritative: it refreshes only missing or stale - * entries so it does not discard fresher results collected from the Site Health - * screen, which also include the asynchronous tests that require JavaScript to run. + * Only verified results are included. An unavailable asynchronous test is still + * reflected in the aggregate counts, but does not replace a previously verified + * detailed result. */ - self::update_site_status_detail( $results_to_store, false ); + self::update_site_status_detail( $results_to_store ); } /** @@ -3657,19 +3661,11 @@ public static function get_site_status_detail(): array { * * @since 7.1.0 * - * @param array $results Map of raw Site Health test result arrays, - * keyed by test name. - * @param bool $includes_async Whether the results include the JavaScript-only - * asynchronous tests. The Site Health screen passes - * `true` because it collects those tests in the browser, - * so its entries take precedence over existing cached - * ones. The scheduled check passes `false` since it cannot - * run the asynchronous tests, so it only fills in missing - * or stale entries without discarding fresher results from - * the screen. + * @param array $results Map of raw Site Health test result arrays, + * keyed by test name. * @return true|WP_Error True on success, WP_Error on failure. */ - public static function update_site_status_detail( array $results, bool $includes_async ) { + public static function update_site_status_detail( array $results ) { $now = time(); $cached = self::read_site_status_detail_cache(); @@ -3690,20 +3686,6 @@ public static function update_site_status_detail( array $results, bool $includes } /** @var Stored_Test_Result $result */ - /* - * When the results omit the asynchronous tests, keep a recent existing entry - * rather than overwriting it. This preserves results collected from the Site - * Health screen (including the asynchronous tests) when the scheduled check - * runs afterwards. - */ - if ( - ! $includes_async - && isset( $cached['results'][ $test ] ) - && ( $now - $cached['results'][ $test ]['timestamp'] ) < WEEK_IN_SECONDS - ) { - continue; - } - $cached['results'][ $test ] = $result; } diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index a9006e5d45fb8..0a9155cb539d3 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -958,8 +958,7 @@ public function test_only_valid_results_are_cached(): void { 'invalid_status' => array( 'status' => 'invalid', ), - ), - true + ) ); $this->assertWPError( $result ); @@ -967,14 +966,24 @@ public function test_only_valid_results_are_cached(): void { } /** - * An asynchronous test that is unavailable during the scheduled check is cached under - * its own identifier, so the counts and the detailed results stay consistent. + * An unavailable asynchronous test is counted but does not replace a previously + * verified detailed result. * * @ticket 65232 * * @covers ::wp_cron_scheduled_check */ - public function test_scheduled_check_caches_unavailable_async_test_with_identifier(): void { + public function test_scheduled_check_preserves_verified_result_when_async_test_is_unavailable(): void { + $cached_at = time() - 2 * WEEK_IN_SECONDS; + $this->seed_site_status_detail( + array( + 'my_async' => array( + 'status' => 'good', + ), + ), + $cached_at + ); + // Force the asynchronous remote request to fail so the fallback path runs. $http = static function () { return new WP_Error( 'unavailable', 'Service unavailable.' ); @@ -995,8 +1004,6 @@ public function test_scheduled_check_caches_unavailable_async_test_with_identifi }; add_filter( 'site_status_tests', $filter ); - delete_transient( WP_Site_Health::STATUS_DETAIL_TRANSIENT ); - $this->instance->wp_cron_scheduled_check(); remove_filter( 'site_status_tests', $filter ); @@ -1005,21 +1012,21 @@ public function test_scheduled_check_caches_unavailable_async_test_with_identifi $detail = WP_Site_Health::get_site_status_detail(); $results_by_id = $detail['results']; - $this->assertArrayHasKey( 'my_async', $results_by_id, 'The unavailable async test should be cached under its identifier.' ); - $this->assertSame( 'recommended', $results_by_id['my_async']['status'] ); - $this->assertSame( 1, $detail['counts']['recommended'], 'The unavailable async test should also be reflected in the detail counts.' ); + $this->assertArrayHasKey( 'my_async', $results_by_id ); + $this->assertSame( 'good', $results_by_id['my_async']['status'] ); + $this->assertSame( $cached_at, $results_by_id['my_async']['timestamp'] ); + $this->assertSame( 1, WP_Site_Health::get_site_status_counts()['recommended'] ); } /** - * The scheduled check does not overwrite recently cached results, preserving the - * authoritative results collected from the Site Health screen (including async tests). + * The scheduled check overwrites an existing result when it collects a newer verified result. * * @ticket 65232 * * @covers ::wp_cron_scheduled_check * @covers ::update_site_status_detail */ - public function test_scheduled_check_preserves_fresh_results(): void { + public function test_scheduled_check_stores_freshest_verified_result(): void { $this->seed_site_status_detail( array( 'fake_async' => array( @@ -1045,44 +1052,7 @@ public function test_scheduled_check_preserves_fresh_results(): void { $detail = WP_Site_Health::get_site_status_detail(); $this->assertCount( 1, $detail['results'] ); - $this->assertSame( 'good', array_first( $detail['results'] )['status'], 'The fresh browser result should be preserved, not overwritten by cron.' ); - } - - /** - * The scheduled check refreshes results that are older than the cron cadence. - * - * @ticket 65232 - * - * @covers ::wp_cron_scheduled_check - * @covers ::update_site_status_detail - */ - public function test_scheduled_check_refreshes_stale_results(): void { - $this->seed_site_status_detail( - array( - 'fake_test' => array( - 'test' => 'fake_test', - 'status' => 'good', - ), - ), - time() - 2 * WEEK_IN_SECONDS - ); - - $filter = $this->use_fake_site_status_tests( - array( - 'fake_test' => array( - 'test' => 'fake_test', - 'status' => 'critical', - ), - ) - ); - - $this->instance->wp_cron_scheduled_check(); - - remove_filter( 'site_status_tests', $filter ); - - $detail = WP_Site_Health::get_site_status_detail(); - $this->assertCount( 1, $detail['results'] ); - $this->assertSame( 'critical', array_first( $detail['results'] )['status'], 'A stale result should be refreshed by cron.' ); + $this->assertSame( 'critical', array_first( $detail['results'] )['status'] ); } /** @@ -1116,7 +1086,7 @@ public function test_update_site_status_detail_drops_stale_entries(): void { ); // Update with nothing new; the stale-entry pruning still runs. - WP_Site_Health::update_site_status_detail( array(), true ); + WP_Site_Health::update_site_status_detail( array() ); $detail = WP_Site_Health::get_site_status_detail(); $this->assertCount( 1, $detail['results'] ); @@ -1177,7 +1147,7 @@ public function test_get_site_status_detail_distinguishes_empty_from_missing_or_ $this->assertSame( 0, $detail['timestamp'] ); $before = time(); - $this->assertTrue( WP_Site_Health::update_site_status_detail( array(), true ) ); + $this->assertTrue( WP_Site_Health::update_site_status_detail( array() ) ); $after = time(); $detail = WP_Site_Health::get_site_status_detail(); From 165fbeb73a5203eec801f74b3811b85e67d52a61 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 24 Jul 2026 12:49:11 +0200 Subject: [PATCH 40/40] Site Health: Add status cache error messages --- src/wp-admin/includes/class-wp-site-health.php | 6 +++--- tests/phpunit/tests/admin/wpSiteHealth.php | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 7db471fbc3bff..df6e641edb154 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -3620,7 +3620,7 @@ public static function set_site_status_counts( array $counts ) { } if ( ! set_transient( self::STATUS_RESULT_TRANSIENT, wp_json_encode( $counts ) ) ) { - return new WP_Error( 'set_transient_error' ); + return new WP_Error( 'set_transient_error', __( 'The Site Health status counts could not be saved.' ) ); } return true; @@ -3673,7 +3673,7 @@ public static function update_site_status_detail( array $results ) { foreach ( $results as $test => $result ) { $test = sanitize_text_field( $test ); if ( '' === $test ) { - return new WP_Error( 'invalid_test' ); + return new WP_Error( 'invalid_test', __( 'The Site Health test identifier is invalid.' ) ); } if ( is_array( $result ) ) { @@ -3707,7 +3707,7 @@ public static function update_site_status_detail( array $results ) { } if ( ! set_transient( self::STATUS_DETAIL_TRANSIENT, $stored_value, MONTH_IN_SECONDS ) ) { - return new WP_Error( 'set_transient_error' ); + return new WP_Error( 'set_transient_error', __( 'The detailed Site Health status could not be saved.' ) ); } return true; } diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 0a9155cb539d3..46990f261b97f 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -962,6 +962,18 @@ public function test_only_valid_results_are_cached(): void { ); $this->assertWPError( $result ); + + $result = WP_Site_Health::update_site_status_detail( + array( + '' => array( + 'status' => 'good', + ), + ) + ); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid_test', $result->get_error_code() ); + $this->assertNotSame( '', $result->get_error_message() ); $this->assertSame( array( 'valid_result' ), array_keys( WP_Site_Health::get_site_status_detail()['results'] ) ); }