Skip to content

Commit ea77bb1

Browse files
westonruterGilmoursatejas0306
authored
Merge pull request #2481 from Gilmoursa/fix/2417-enqueued-assets-zero-size-no-store
Enqueued Assets audit: treat zero-size and no-store responses as errors Co-authored-by: Gilmoursa <gilmoursa@git.wordpress.org> Co-authored-by: westonruter <westonruter@git.wordpress.org> Co-authored-by: tejas0306 <suhan2411@git.wordpress.org>
2 parents f531143 + 3dfa7b5 commit ea77bb1

3 files changed

Lines changed: 272 additions & 8 deletions

File tree

‎plugins/performance-lab/includes/site-health/audit-enqueued-assets/helper.php‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -524,9 +524,83 @@ function perflab_aea_get_asset_size( string $resource_url ) {
524524
);
525525
}
526526

527-
// TODO: A non-cacheable response should also be considered an error.
528-
// TODO: A size of zero could be considered an error too.
529-
return strlen( wp_remote_retrieve_body( $response ) );
527+
$body = wp_remote_retrieve_body( $response );
528+
529+
if ( strlen( $body ) === 0 ) {
530+
return new WP_Error(
531+
'zero_size',
532+
esc_html__( 'The asset returned an empty response body.', 'performance-lab' )
533+
);
534+
}
535+
536+
$cache_control = wp_remote_retrieve_header( $response, 'cache-control' );
537+
if ( is_array( $cache_control ) ) {
538+
$cache_control = implode( ', ', $cache_control );
539+
}
540+
if ( '' !== $cache_control ) {
541+
// Parse the directives into a name => value map so values like max-age can be compared numerically.
542+
$directives = array();
543+
foreach ( explode( ',', strtolower( $cache_control ) ) as $directive ) {
544+
$parts = explode( '=', $directive, 2 );
545+
$name = trim( $parts[0] );
546+
$directives[ $name ] = isset( $parts[1] ) ? trim( $parts[1], " \t\n\r\0\x0B\"'" ) : '';
547+
}
548+
if ( array_key_exists( 'no-store', $directives ) ) {
549+
// no-store forbids browsers from caching the asset at all.
550+
return new WP_Error(
551+
'not_cacheable',
552+
esc_html__( 'The asset response cannot be cached by browsers.', 'performance-lab' )
553+
);
554+
}
555+
if (
556+
array_key_exists( 'no-cache', $directives ) ||
557+
( array_key_exists( 'max-age', $directives ) && 0 === (int) $directives['max-age'] )
558+
) {
559+
// no-cache and max-age=0 permit caching but force revalidation on every request.
560+
return new WP_Error(
561+
'not_cacheable',
562+
esc_html__( 'The asset response may not be fully cacheable by browsers.', 'performance-lab' )
563+
);
564+
}
565+
} else {
566+
// No Cache-Control header: fall back to Expires.
567+
$expires = wp_remote_retrieve_header( $response, 'expires' );
568+
if ( is_array( $expires ) ) {
569+
$expires = end( $expires );
570+
}
571+
if ( '' !== $expires ) {
572+
// An invalid Expires value is treated as already expired per RFC 7234.
573+
$expires_timestamp = strtotime( $expires );
574+
if ( false === $expires_timestamp || $expires_timestamp <= time() ) {
575+
return new WP_Error(
576+
'not_cacheable',
577+
esc_html__( 'The asset response may not be fully cacheable by browsers.', 'performance-lab' )
578+
);
579+
}
580+
} else {
581+
// No Cache-Control and no Expires: heuristic caching is only viable when a validator
582+
// (Last-Modified or ETag) is present. Without one, the asset effectively cannot be cached.
583+
$has_validator = false;
584+
foreach ( array( 'last-modified', 'etag' ) as $validator_header ) {
585+
$value = wp_remote_retrieve_header( $response, $validator_header );
586+
if ( is_array( $value ) ) {
587+
$value = implode( '', $value );
588+
}
589+
if ( '' !== $value ) {
590+
$has_validator = true;
591+
break;
592+
}
593+
}
594+
if ( ! $has_validator ) {
595+
return new WP_Error(
596+
'not_cacheable',
597+
esc_html__( 'The asset response may not be fully cacheable by browsers.', 'performance-lab' )
598+
);
599+
}
600+
}
601+
}
602+
603+
return strlen( $body );
530604
}
531605

532606
/**

‎plugins/performance-lab/tests/data/class-audit-assets-mock-assets.php‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ public static function mock_assets( string $type, int $number_of_assets = 5, int
6060
);
6161
} else {
6262
self::$mocked_responses[ $src ] = array(
63-
'code' => 200,
64-
'body' => str_repeat( 'A', 1000 ),
63+
'code' => 200,
64+
'body' => str_repeat( 'A', 1000 ),
65+
'headers' => array( 'cache-control' => 'max-age=3600' ),
6566
);
6667
$assets[ $type ][] = array(
6768
'src' => $src,
@@ -156,6 +157,7 @@ static function ( $preempt, $parsed_args, $url ) {
156157
return array(
157158
'response' => self::$mocked_responses[ $url ],
158159
'body' => self::$mocked_responses[ $url ]['body'] ?? '',
160+
'headers' => self::$mocked_responses[ $url ]['headers'] ?? array(),
159161
);
160162
}
161163
return $preempt;

‎plugins/performance-lab/tests/includes/site-health/audit-enqueued-assets/test-audit-enqueued-assets-helper.php‎

Lines changed: 191 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,19 +511,207 @@ public function test_perflab_aea_get_asset_size(): void {
511511
array(
512512
'url' => 'https://example.com/script4.js',
513513
'response' => array(
514-
'code' => 200,
515-
'body' => str_repeat( 'A', 1000 ),
514+
'code' => 200,
515+
'body' => str_repeat( 'A', 1000 ),
516+
'headers' => array( 'cache-control' => 'max-age=3600' ),
516517
),
517518
),
518519
)
519520
);
520521

521522
$this->assertWPError( perflab_aea_get_asset_size( 'https://example.com/script1.js' ) );
522523
$this->assertWPError( perflab_aea_get_asset_size( 'https://example.com/script2.js' ) );
523-
$this->assertEquals( 0, perflab_aea_get_asset_size( 'https://example.com/script3.js' ) );
524+
$zero_size_result = perflab_aea_get_asset_size( 'https://example.com/script3.js' );
525+
$this->assertWPError( $zero_size_result );
526+
$this->assertSame( 'zero_size', $zero_size_result->get_error_code() );
524527
$this->assertEquals( 1000, perflab_aea_get_asset_size( 'https://example.com/script4.js' ) );
525528
}
526529

530+
/**
531+
* Tests perflab_aea_get_asset_size() with non-cacheable responses.
532+
*
533+
* @covers ::perflab_aea_get_asset_size
534+
*/
535+
public function test_perflab_aea_get_asset_size_not_cacheable(): void {
536+
Audit_Assets_Mock_Assets::clear_mocked();
537+
Audit_Assets_Mock_Assets::mock_requests(
538+
array(
539+
array(
540+
'url' => 'https://example.com/no-store.js',
541+
'response' => array(
542+
'code' => 200,
543+
'body' => str_repeat( 'A', 500 ),
544+
'headers' => array( 'cache-control' => 'no-store' ),
545+
),
546+
),
547+
array(
548+
'url' => 'https://example.com/no-store-with-extras.js',
549+
'response' => array(
550+
'code' => 200,
551+
'body' => str_repeat( 'A', 500 ),
552+
'headers' => array( 'cache-control' => 'no-store, must-revalidate' ),
553+
),
554+
),
555+
array(
556+
'url' => 'https://example.com/no-cache.js',
557+
'response' => array(
558+
'code' => 200,
559+
'body' => str_repeat( 'A', 500 ),
560+
'headers' => array( 'cache-control' => 'no-cache' ),
561+
),
562+
),
563+
array(
564+
'url' => 'https://example.com/max-age-zero.js',
565+
'response' => array(
566+
'code' => 200,
567+
'body' => str_repeat( 'A', 500 ),
568+
'headers' => array( 'cache-control' => 'max-age=0' ),
569+
),
570+
),
571+
array(
572+
'url' => 'https://example.com/cacheable.js',
573+
'response' => array(
574+
'code' => 200,
575+
'body' => str_repeat( 'A', 500 ),
576+
'headers' => array( 'cache-control' => 'max-age=3600' ),
577+
),
578+
),
579+
array(
580+
'url' => 'https://example.com/max-age-zero-quoted.js',
581+
'response' => array(
582+
'code' => 200,
583+
'body' => str_repeat( 'A', 500 ),
584+
'headers' => array( 'cache-control' => 'max-age="0"' ),
585+
),
586+
),
587+
array(
588+
'url' => 'https://example.com/max-age-zero-spaced.js',
589+
'response' => array(
590+
'code' => 200,
591+
'body' => str_repeat( 'A', 500 ),
592+
'headers' => array( 'cache-control' => 'public, max-age = 0' ),
593+
),
594+
),
595+
array(
596+
'url' => 'https://example.com/no-store-array-header.js',
597+
'response' => array(
598+
'code' => 200,
599+
'body' => str_repeat( 'A', 500 ),
600+
'headers' => array( 'cache-control' => array( 'no-store', 'must-revalidate' ) ),
601+
),
602+
),
603+
array(
604+
'url' => 'https://example.com/expires-past.js',
605+
'response' => array(
606+
'code' => 200,
607+
'body' => str_repeat( 'A', 500 ),
608+
'headers' => array( 'expires' => 'Thu, 01 Jan 1970 00:00:00 GMT' ),
609+
),
610+
),
611+
array(
612+
'url' => 'https://example.com/expires-future.js',
613+
'response' => array(
614+
'code' => 200,
615+
'body' => str_repeat( 'A', 500 ),
616+
'headers' => array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + 3600 ) . ' GMT' ),
617+
),
618+
),
619+
array(
620+
'url' => 'https://example.com/expires-invalid.js',
621+
'response' => array(
622+
'code' => 200,
623+
'body' => str_repeat( 'A', 500 ),
624+
'headers' => array( 'expires' => '0' ),
625+
),
626+
),
627+
array(
628+
'url' => 'https://example.com/no-headers.js',
629+
'response' => array(
630+
'code' => 200,
631+
'body' => str_repeat( 'A', 500 ),
632+
'headers' => array(),
633+
),
634+
),
635+
array(
636+
'url' => 'https://example.com/last-modified-only.js',
637+
'response' => array(
638+
'code' => 200,
639+
'body' => str_repeat( 'A', 500 ),
640+
'headers' => array( 'last-modified' => 'Mon, 01 Jan 2024 00:00:00 GMT' ),
641+
),
642+
),
643+
array(
644+
'url' => 'https://example.com/etag-only.js',
645+
'response' => array(
646+
'code' => 200,
647+
'body' => str_repeat( 'A', 500 ),
648+
'headers' => array( 'etag' => '"abc123"' ),
649+
),
650+
),
651+
)
652+
);
653+
654+
$no_store_result = perflab_aea_get_asset_size( 'https://example.com/no-store.js' );
655+
$this->assertWPError( $no_store_result );
656+
$this->assertSame( 'not_cacheable', $no_store_result->get_error_code() );
657+
658+
$no_store_extras_result = perflab_aea_get_asset_size( 'https://example.com/no-store-with-extras.js' );
659+
$this->assertWPError( $no_store_extras_result );
660+
$this->assertSame( 'not_cacheable', $no_store_extras_result->get_error_code() );
661+
662+
// no-cache forces revalidation on every request and should be flagged.
663+
$no_cache_result = perflab_aea_get_asset_size( 'https://example.com/no-cache.js' );
664+
$this->assertWPError( $no_cache_result );
665+
$this->assertSame( 'not_cacheable', $no_cache_result->get_error_code() );
666+
667+
// max-age=0 forces revalidation on every request and should be flagged.
668+
$max_age_zero_result = perflab_aea_get_asset_size( 'https://example.com/max-age-zero.js' );
669+
$this->assertWPError( $max_age_zero_result );
670+
$this->assertSame( 'not_cacheable', $max_age_zero_result->get_error_code() );
671+
672+
// Normal cacheable response should return size.
673+
$this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/cacheable.js' ) );
674+
675+
// max-age with a quoted zero value should be flagged.
676+
$max_age_zero_quoted_result = perflab_aea_get_asset_size( 'https://example.com/max-age-zero-quoted.js' );
677+
$this->assertWPError( $max_age_zero_quoted_result );
678+
$this->assertSame( 'not_cacheable', $max_age_zero_quoted_result->get_error_code() );
679+
680+
// max-age with whitespace around the equals sign and a zero value should be flagged.
681+
$max_age_zero_spaced_result = perflab_aea_get_asset_size( 'https://example.com/max-age-zero-spaced.js' );
682+
$this->assertWPError( $max_age_zero_spaced_result );
683+
$this->assertSame( 'not_cacheable', $max_age_zero_spaced_result->get_error_code() );
684+
685+
// Cache-Control header returned as an array (multiple headers) should still be detected.
686+
$no_store_array_result = perflab_aea_get_asset_size( 'https://example.com/no-store-array-header.js' );
687+
$this->assertWPError( $no_store_array_result );
688+
$this->assertSame( 'not_cacheable', $no_store_array_result->get_error_code() );
689+
690+
// Expires in the past with no Cache-Control should be flagged.
691+
$expires_past_result = perflab_aea_get_asset_size( 'https://example.com/expires-past.js' );
692+
$this->assertWPError( $expires_past_result );
693+
$this->assertSame( 'not_cacheable', $expires_past_result->get_error_code() );
694+
695+
// Expires in the future with no Cache-Control should return size.
696+
$this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/expires-future.js' ) );
697+
698+
// An invalid Expires value is treated as already expired per RFC 7234 and should be flagged.
699+
$expires_invalid_result = perflab_aea_get_asset_size( 'https://example.com/expires-invalid.js' );
700+
$this->assertWPError( $expires_invalid_result );
701+
$this->assertSame( 'not_cacheable', $expires_invalid_result->get_error_code() );
702+
703+
// No Cache-Control, no Expires, and no validator should be flagged.
704+
$no_headers_result = perflab_aea_get_asset_size( 'https://example.com/no-headers.js' );
705+
$this->assertWPError( $no_headers_result );
706+
$this->assertSame( 'not_cacheable', $no_headers_result->get_error_code() );
707+
708+
// No Cache-Control and no Expires but a Last-Modified validator allows heuristic caching, so it should return size.
709+
$this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/last-modified-only.js' ) );
710+
711+
// No Cache-Control and no Expires but an ETag validator allows heuristic caching, so it should return size.
712+
$this->assertEquals( 500, perflab_aea_get_asset_size( 'https://example.com/etag-only.js' ) );
713+
}
714+
527715
/**
528716
* Tests perflab_aea_copy_basic_auth_headers() with various scenarios.
529717
*

0 commit comments

Comments
 (0)