From 4bcfe319fcf4ce40806d25c977d4bbb62ce3482f Mon Sep 17 00:00:00 2001 From: Austin Gilmour Date: Fri, 15 May 2026 10:58:33 -0400 Subject: [PATCH 1/3] Enqueued Assets audit: treat zero-size and no-store responses as errors In perflab_aea_get_asset_size(), two cases that should have been treated as errors were silently passing the audit: - A 200 response with an empty body now returns WP_Error('zero_size'). An asset that returns nothing is effectively broken, regardless of the status code. - A 200 response with Cache-Control: no-store now returns WP_Error('not_cacheable'). An asset that browsers are forbidden from caching is a clear performance problem and should surface in the audit. Cache-Control: no-cache (revalidate) is intentionally left as passing since the asset can still be served from cache after revalidation. Both cases were already noted as TODOs in the original code. Removes those TODO comments and adds tests covering zero-size, no-store, no-cache (should pass), and normal cacheable responses. Also updates the mock class to pass response headers through so header-dependent tests can work end-to-end. Fixes #2417. Co-Authored-By: Claude Sonnet 4.6 --- .../audit-enqueued-assets/helper.php | 24 ++++++- .../data/class-audit-assets-mock-assets.php | 1 + .../test-audit-enqueued-assets-helper.php | 63 ++++++++++++++++++- 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php b/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php index b528821a28..3d2e0eac46 100644 --- a/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php +++ b/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php @@ -524,9 +524,27 @@ function perflab_aea_get_asset_size( string $resource_url ) { ); } - // TODO: A non-cacheable response should also be considered an error. - // TODO: A size of zero could be considered an error too. - return strlen( wp_remote_retrieve_body( $response ) ); + $body = wp_remote_retrieve_body( $response ); + + if ( strlen( $body ) === 0 ) { + return new WP_Error( + 'zero_size', + esc_html__( 'The asset returned an empty response body.', 'performance-lab' ) + ); + } + + $cache_control = wp_remote_retrieve_header( $response, 'cache-control' ); + if ( '' !== $cache_control ) { + $directives = array_map( 'trim', explode( ',', strtolower( $cache_control ) ) ); + if ( in_array( 'no-store', $directives, true ) ) { + return new WP_Error( + 'not_cacheable', + esc_html__( 'The asset response has a Cache-Control: no-store directive and will not be cached by browsers.', 'performance-lab' ) + ); + } + } + + return strlen( $body ); } /** diff --git a/plugins/performance-lab/tests/data/class-audit-assets-mock-assets.php b/plugins/performance-lab/tests/data/class-audit-assets-mock-assets.php index 10b9b793ff..f4406d7bbc 100644 --- a/plugins/performance-lab/tests/data/class-audit-assets-mock-assets.php +++ b/plugins/performance-lab/tests/data/class-audit-assets-mock-assets.php @@ -156,6 +156,7 @@ static function ( $preempt, $parsed_args, $url ) { return array( 'response' => self::$mocked_responses[ $url ], 'body' => self::$mocked_responses[ $url ]['body'] ?? '', + 'headers' => self::$mocked_responses[ $url ]['headers'] ?? array(), ); } return $preempt; diff --git a/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php b/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php index e3939b8190..e1ec5e8a5b 100644 --- a/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php +++ b/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php @@ -520,10 +520,71 @@ public function test_perflab_aea_get_asset_size(): void { $this->assertWPError( perflab_aea_get_asset_size( 'https://example.com/script1.js' ) ); $this->assertWPError( perflab_aea_get_asset_size( 'https://example.com/script2.js' ) ); - $this->assertEquals( 0, perflab_aea_get_asset_size( 'https://example.com/script3.js' ) ); + $zero_size_result = perflab_aea_get_asset_size( 'https://example.com/script3.js' ); + $this->assertWPError( $zero_size_result ); + $this->assertSame( 'zero_size', $zero_size_result->get_error_code() ); $this->assertEquals( 1000, perflab_aea_get_asset_size( 'https://example.com/script4.js' ) ); } + /** + * Tests perflab_aea_get_asset_size() with a Cache-Control: no-store response. + * + * @covers ::perflab_aea_get_asset_size + */ + public function test_perflab_aea_get_asset_size_not_cacheable(): void { + Audit_Assets_Mock_Assets::clear_mocked(); + Audit_Assets_Mock_Assets::mock_requests( + array( + array( + 'url' => 'https://example.com/no-store.js', + 'response' => array( + 'code' => 200, + 'body' => str_repeat( 'A', 500 ), + 'headers' => array( 'cache-control' => 'no-store' ), + ), + ), + array( + 'url' => 'https://example.com/no-store-with-extras.js', + 'response' => array( + 'code' => 200, + 'body' => str_repeat( 'A', 500 ), + 'headers' => array( 'cache-control' => 'no-store, must-revalidate' ), + ), + ), + array( + 'url' => 'https://example.com/no-cache.js', + 'response' => array( + 'code' => 200, + 'body' => str_repeat( 'A', 500 ), + 'headers' => array( 'cache-control' => 'no-cache' ), + ), + ), + array( + 'url' => 'https://example.com/cacheable.js', + 'response' => array( + 'code' => 200, + 'body' => str_repeat( 'A', 500 ), + 'headers' => array( 'cache-control' => 'max-age=3600' ), + ), + ), + ) + ); + + $no_store_result = perflab_aea_get_asset_size( 'https://example.com/no-store.js' ); + $this->assertWPError( $no_store_result ); + $this->assertSame( 'not_cacheable', $no_store_result->get_error_code() ); + + $no_store_extras_result = perflab_aea_get_asset_size( 'https://example.com/no-store-with-extras.js' ); + $this->assertWPError( $no_store_extras_result ); + $this->assertSame( 'not_cacheable', $no_store_extras_result->get_error_code() ); + + // no-cache means revalidate, not no-store — should not be flagged. + $this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/no-cache.js' ) ); + + // Normal cacheable response should return size. + $this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/cacheable.js' ) ); + } + /** * Tests perflab_aea_copy_basic_auth_headers() with various scenarios. * From d143037e9644b4dc92afa64effa8604a9ad56129 Mon Sep 17 00:00:00 2001 From: Austin Gilmour Date: Fri, 15 May 2026 11:09:43 -0400 Subject: [PATCH 2/3] Fix PHPStan: handle array return from wp_remote_retrieve_header() wp_remote_retrieve_header() returns string|array (array when the same header appears multiple times in the response). Joining array values with implode() before passing to strtolower() resolves the PHPStan error and also correctly handles multi-value Cache-Control headers. Co-Authored-By: Claude Sonnet 4.6 --- .../includes/site-health/audit-enqueued-assets/helper.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php b/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php index 3d2e0eac46..393b2dae00 100644 --- a/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php +++ b/plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php @@ -534,6 +534,9 @@ function perflab_aea_get_asset_size( string $resource_url ) { } $cache_control = wp_remote_retrieve_header( $response, 'cache-control' ); + if ( is_array( $cache_control ) ) { + $cache_control = implode( ', ', $cache_control ); + } if ( '' !== $cache_control ) { $directives = array_map( 'trim', explode( ',', strtolower( $cache_control ) ) ); if ( in_array( 'no-store', $directives, true ) ) { From 48748c93c62c2ff2668f10ea694df0472a35abcb Mon Sep 17 00:00:00 2001 From: Austin Gilmour Date: Fri, 15 May 2026 13:06:45 -0400 Subject: [PATCH 3/3] Tests: add coverage for array-valued Cache-Control header in asset size check Co-Authored-By: Claude Sonnet 4.6 --- .../test-audit-enqueued-assets-helper.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php b/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php index e1ec5e8a5b..68efda134f 100644 --- a/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php +++ b/plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php @@ -567,6 +567,14 @@ public function test_perflab_aea_get_asset_size_not_cacheable(): void { 'headers' => array( 'cache-control' => 'max-age=3600' ), ), ), + array( + 'url' => 'https://example.com/no-store-array-header.js', + 'response' => array( + 'code' => 200, + 'body' => str_repeat( 'A', 500 ), + 'headers' => array( 'cache-control' => array( 'no-store', 'must-revalidate' ) ), + ), + ), ) ); @@ -583,6 +591,11 @@ public function test_perflab_aea_get_asset_size_not_cacheable(): void { // Normal cacheable response should return size. $this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/cacheable.js' ) ); + + // Cache-Control header returned as an array (multiple headers) should still be detected. + $no_store_array_result = perflab_aea_get_asset_size( 'https://example.com/no-store-array-header.js' ); + $this->assertWPError( $no_store_array_result ); + $this->assertSame( 'not_cacheable', $no_store_array_result->get_error_code() ); } /**