From 9afb86af115014fbded7abd3815a6d7281b56ef7 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 15 Jun 2026 15:53:19 +0200 Subject: [PATCH 01/11] 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 75e046ef8ffa7..5fcba4725af37 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 418fb026cde1b6d1ab0d4453a2f6733558b237f1 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 15 Jun 2026 16:49:51 +0200 Subject: [PATCH 02/11] 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 68a05acb32188b1658910e4814f378326cc4968e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:37:03 -0700 Subject: [PATCH 03/11] 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 5fcba4725af37..d561ac63e77fa 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 b9f7bd03955e3592eb2ae5746a1fbca223403061 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:40:28 -0700 Subject: [PATCH 04/11] 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 d1aafd63826644a29305cfa4084e80ef1b9dfef2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:41:48 -0700 Subject: [PATCH 05/11] 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 d561ac63e77fa..95f41d7497cdc 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 d2fda8fb7d4a98f18cafa8de27928fa9e889fd56 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:49:59 -0700 Subject: [PATCH 06/11] 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 1e7d0c13e924fb001a27d383c25de15e8bd924f7 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:51:09 -0700 Subject: [PATCH 07/11] 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 8e3493e5210416de0765a55d85214e6f8799cddd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:51:50 -0700 Subject: [PATCH 08/11] 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 f44afb99c567d96bb5fa3adfb44f8d2a78b567e1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 11:53:09 -0700 Subject: [PATCH 09/11] 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 59f54968a78d39351eb1fb4c8a15cfb376a0a24b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Jun 2026 12:11:00 -0700 Subject: [PATCH 10/11] 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 70b056628078488b8ab946927218c6a1faea235b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 16 Jun 2026 12:11:00 +0200 Subject: [PATCH 11/11] 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 95f41d7497cdc..60249d48cfee3 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( '