diff --git a/plugins/performance-lab/includes/site-health/far-future-headers/helper.php b/plugins/performance-lab/includes/site-health/far-future-headers/helper.php new file mode 100644 index 0000000000..88b2d322a8 --- /dev/null +++ b/plugins/performance-lab/includes/site-health/far-future-headers/helper.php @@ -0,0 +1,320 @@ + __( 'Your site serves static assets with an effective caching strategy', 'performance-lab' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Performance', 'performance-lab' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + esc_html__( + 'Serving static assets with far-future expiration headers improves performance by allowing browsers to cache files for a long time, reducing repeated requests.', + 'performance-lab' + ) + ), + 'actions' => '', + 'test' => 'is_far_future_headers_enabled', + ); + + // List of assets to check. + $assets = array( + includes_url( 'js/wp-embed.min.js' ), + includes_url( 'css/buttons.min.css' ), + includes_url( 'fonts/dashicons.woff2' ), + includes_url( 'images/media/video.png' ), + ); + + /** + * Filters the list of assets to check for far-future headers. + * + * @since n.e.x.t + * + * @param string[] $assets List of asset URLs to check. + */ + $assets = apply_filters( 'perflab_ffh_assets_to_check', $assets ); + $assets = array_filter( (array) $assets, 'is_string' ); + + // Check if far-future headers are enabled for all assets. + $results = perflab_ffh_check_assets( $assets ); + + if ( 'good' !== $results['final_status'] ) { + $result['status'] = $results['final_status']; + $result['label'] = __( 'Your site does not serve static assets with an effective caching strategy', 'performance-lab' ); + + if ( count( $results['details'] ) > 0 ) { + $result['actions'] = sprintf( + '

%s

%s

%s

', + esc_html__( 'The following file types do not have the recommended far-future headers. Consider adding or adjusting Cache-Control or Expires headers for these asset types.', 'performance-lab' ), + perflab_ffh_get_extensions_table( $results['details'] ), + esc_html__( 'Note: "Conditionally cached" means that the browser can re-validate the resource using ETag or Last-Modified headers. This results in fewer full downloads but still requires the browser to make requests, unlike far-future expiration headers that allow the browser to fully rely on its local cache for a longer duration.', 'performance-lab' ) + ); + } + $result['actions'] .= sprintf( + '

%s

', + esc_html__( 'Far-future Cache-Control or Expires headers can be added or adjusted with a small configuration change by your hosting provider.', 'performance-lab' ) + ); + } + + return $result; +} + +/** + * Checks if far-future expiration headers are enabled for a list of assets. + * + * @since n.e.x.t + * @access private + * + * @param string[] $assets List of asset URLs to check. + * @return array{final_status: string, details: array{filename: string, reason: string}[]} Final status and details. + */ +function perflab_ffh_check_assets( array $assets ): array { + $final_status = 'good'; + $fail_details = array(); // Array of arrays with 'filename' and 'reason'. + + foreach ( $assets as $asset ) { + $response = wp_remote_get( $asset, array( 'sslverify' => false ) ); + + // Extract filename from the URL. + $path_info = pathinfo( (string) wp_parse_url( $asset, PHP_URL_PATH ) ); + $filename = $path_info['basename'] ?? basename( $asset ); + + if ( is_wp_error( $response ) ) { + // Can't determine headers if request failed, consider it a fail. + $final_status = 'recommended'; + $fail_details[] = array( + 'filename' => $filename, + 'reason' => __( 'Could not retrieve headers', 'performance-lab' ), + ); + continue; + } + + $headers = wp_remote_retrieve_headers( $response ); + if ( ! is_object( $headers ) && 0 === count( $headers ) ) { + // No valid headers retrieved. + $final_status = 'recommended'; + $fail_details[] = array( + 'filename' => $filename, + 'reason' => __( 'No valid headers retrieved', 'performance-lab' ), + ); + continue; + } + + $check = perflab_ffh_check_headers( $headers ); + if ( isset( $check['passed'] ) && $check['passed'] ) { + // This asset passed far-future headers test, no action needed. + continue; + } + + // If not passed, decide whether to try conditional request. + if ( false === $check ) { + // Only if no far-future headers at all, we try conditional request. + $conditional_pass = perflab_ffh_try_conditional_request( $asset, $headers ); + $final_status = 'recommended'; + if ( ! $conditional_pass ) { + $fail_details[] = array( + 'filename' => $filename, + 'reason' => __( 'No far-future headers and no conditional caching', 'performance-lab' ), + ); + } else { + $fail_details[] = array( + 'filename' => $filename, + 'reason' => __( 'No far-future headers but conditionally cached', 'performance-lab' ), + ); + } + } else { + // If there's a max-age or expires but below threshold, we skip conditional. + $final_status = 'recommended'; + $fail_details[] = array( + 'filename' => $filename, + 'reason' => $check['reason'], + ); + } + } + + return array( + 'final_status' => $final_status, + 'details' => $fail_details, + ); +} + +/** + * Checks if far-future expiration headers are enabled. + * + * @since n.e.x.t + * @access private + * + * @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array> $headers Response headers. + * @return array{passed: bool, reason: string}|false Detailed result. If passed=false, reason explains why it failed and false if no headers found. + */ +function perflab_ffh_check_headers( $headers ) { + /** + * Filters the threshold for far-future headers. + * + * @since n.e.x.t + * + * @param int $threshold Threshold in seconds. + */ + $threshold = apply_filters( 'perflab_far_future_headers_threshold', YEAR_IN_SECONDS ); + + $cache_control = $headers['cache-control'] ?? ''; + $expires = $headers['expires'] ?? ''; + + // Check Cache-Control header for max-age. + $max_age = 0; + if ( '' !== $cache_control ) { + // There can be multiple cache-control headers, we only care about max-age. + foreach ( (array) $cache_control as $control ) { + if ( 1 === preg_match( '/max-age\s*=\s*(\d+)/', $control, $matches ) ) { + $max_age = (int) $matches[1]; + break; + } + } + } + + // If max-age meets or exceeds the threshold, we consider it good. + if ( $max_age >= $threshold ) { + return array( + 'passed' => true, + 'reason' => '', + ); + } + + // If max-age is too low or not present, check Expires. + if ( is_string( $expires ) && '' !== $expires ) { + $expires_time = strtotime( $expires ); + $remaining_time = is_int( $expires_time ) ? $expires_time - time() : 0; + if ( $remaining_time >= $threshold ) { + // Good - Expires far in the future. + return array( + 'passed' => true, + 'reason' => '', + ); + } + + // Expires header exists but not far enough in the future. + if ( $max_age > 0 ) { + return array( + 'passed' => false, + 'reason' => sprintf( + /* translators: 1: actual max-age value in seconds, 2: threshold in seconds */ + __( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), + number_format_i18n( $max_age ), + number_format_i18n( $threshold ) + ), + ); + } + return array( + 'passed' => false, + 'reason' => sprintf( + /* translators: 1: actual Expires header value in seconds, 2: threshold in seconds */ + __( 'expires below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), + number_format_i18n( $remaining_time ), + number_format_i18n( $threshold ) + ), + ); + } + + // No max-age or expires found at all or max-age < threshold and no expires. + if ( 0 === $max_age ) { + return false; + } else { + // max-age was present but below threshold and no expires. + return array( + 'passed' => false, + 'reason' => sprintf( + /* translators: 1: actual max-age value in seconds, 2: threshold in seconds */ + __( 'max-age below threshold (actual: %1$s seconds, threshold: %2$s seconds)', 'performance-lab' ), + number_format_i18n( $max_age ), + number_format_i18n( $threshold ) + ), + ); + } +} + +/** + * Attempt a conditional request with ETag/Last-Modified. + * + * @since n.e.x.t + * @access private + * + * @param string $url The asset URL. + * @param WpOrg\Requests\Utility\CaseInsensitiveDictionary|array> $headers The initial response headers. + * @return bool True if a 304 response was received. + */ +function perflab_ffh_try_conditional_request( string $url, $headers ): bool { + $etag = isset( $headers['etag'] ) ? $headers['etag'] : ''; + $last_modified = isset( $headers['last-modified'] ) ? $headers['last-modified'] : ''; + + $conditional_headers = array(); + if ( '' !== $etag ) { + $conditional_headers['If-None-Match'] = $etag; + } + if ( '' !== $last_modified ) { + $conditional_headers['If-Modified-Since'] = $last_modified; + } + + $response = wp_remote_get( + $url, + array( + 'sslverify' => false, + 'headers' => $conditional_headers, + ) + ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + return ( 304 === $status_code ); +} + +/** + * Generate a table listing files that need far-future headers, including reasons. + * + * @since n.e.x.t + * @access private + * + * @param array $fail_details Array of arrays with 'filename' and 'reason'. + * @return string HTML formatted table. + */ +function perflab_ffh_get_extensions_table( array $fail_details ): string { + $html_table = sprintf( + '', + esc_html__( 'File', 'performance-lab' ), + esc_html__( 'Status', 'performance-lab' ) + ); + + foreach ( $fail_details as $detail ) { + $html_table .= sprintf( + '', + esc_html( $detail['filename'] ), + esc_html( $detail['reason'] ) + ); + } + + $html_table .= '
%s%s
%s%s
'; + + return $html_table; +} diff --git a/plugins/performance-lab/includes/site-health/far-future-headers/hooks.php b/plugins/performance-lab/includes/site-health/far-future-headers/hooks.php new file mode 100644 index 0000000000..ede165b4bc --- /dev/null +++ b/plugins/performance-lab/includes/site-health/far-future-headers/hooks.php @@ -0,0 +1,29 @@ +} $tests Site Health Tests. + * @return array{direct: array} Amended tests. + */ +function perflab_ffh_add_test( array $tests ): array { + $tests['direct']['far_future_headers'] = array( + 'label' => __( 'Effective Caching Headers', 'performance-lab' ), + 'test' => 'perflab_ffh_assets_test', + ); + return $tests; +} +add_filter( 'site_status_tests', 'perflab_ffh_add_test' ); diff --git a/plugins/performance-lab/includes/site-health/load.php b/plugins/performance-lab/includes/site-health/load.php index e4cd71a596..ac0032cf87 100644 --- a/plugins/performance-lab/includes/site-health/load.php +++ b/plugins/performance-lab/includes/site-health/load.php @@ -29,3 +29,7 @@ // AVIF headers site health check. require_once __DIR__ . '/avif-headers/helper.php'; require_once __DIR__ . '/avif-headers/hooks.php'; + +// Far-Future Headers site health check. +require_once __DIR__ . '/far-future-headers/helper.php'; +require_once __DIR__ . '/far-future-headers/hooks.php'; diff --git a/plugins/performance-lab/tests/includes/site-health/far-future-headers/test-far-future-headers.php b/plugins/performance-lab/tests/includes/site-health/far-future-headers/test-far-future-headers.php new file mode 100644 index 0000000000..ba05c58425 --- /dev/null +++ b/plugins/performance-lab/tests/includes/site-health/far-future-headers/test-far-future-headers.php @@ -0,0 +1,203 @@ +> + */ + protected $mocked_responses = array(); + + /** + * Setup each test. + */ + public function setUp(): void { + parent::setUp(); + + // Clear any filters or mocks. + remove_all_filters( 'pre_http_request' ); + + // Add the filter to mock HTTP requests. + add_filter( 'pre_http_request', array( $this, 'mock_http_requests' ), 10, 3 ); + } + + /** + * Test that when all assets have valid far-future headers, the status is "good". + * + * @covers ::perflab_ffh_assets_test + */ + public function test_all_assets_valid_far_future_headers(): void { + // Mock responses: all assets have a max-age > 1 year (threshold). + $this->mocked_responses = array( + includes_url( 'js/wp-embed.min.js' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 1000 ) ) ), + includes_url( 'css/buttons.min.css' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 500 ) ) ), + includes_url( 'fonts/dashicons.woff2' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + YEAR_IN_SECONDS + 1000 ) . ' GMT' ) ), + includes_url( 'images/media/video.png' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 2000 ) ) ), + ); + + $result = perflab_ffh_assets_test(); + $this->assertEquals( 'good', $result['status'] ); + $this->assertEmpty( $result['actions'] ); + } + + /** + * Test that when an asset has no far-future headers but has conditional caching (ETag/Last-Modified), status is 'recommended'. + * + * @covers ::perflab_ffh_assets_test + */ + public function test_assets_conditionally_cached(): void { + // For conditional caching scenario, setting etag/last-modified headers. + $this->mocked_responses = array( + 'js/wp-embed.min.js' => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS + 1000 ) ) ), + 'css/buttons.min.css' => $this->build_response( 200, array( 'etag' => '"123456789"' ) ), + 'fonts/dashicons.woff2' => $this->build_response( 200, array( 'last-modified' => gmdate( 'D, d M Y H:i:s', time() - 1000 ) . ' GMT' ) ), + 'images/media/video.png' => $this->build_response( + 200, + array( + 'etag' => '"123456789"', + 'last-modified' => gmdate( 'D, d M Y H:i:s', time() - 1000 ) . ' GMT', + ) + ), + 'conditional_304' => $this->build_response( 304 ), + ); + + $result = perflab_ffh_assets_test(); + $this->assertEquals( 'recommended', $result['status'] ); + $this->assertNotEmpty( $result['actions'] ); + } + + /** + * Test that different status messages are returned based on the test results. + * + * @covers ::perflab_ffh_check_assets + */ + public function test_status_messages(): void { + $this->mocked_responses = array( + includes_url( 'js/wp-embed.min.js' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( YEAR_IN_SECONDS - 1000 ) ) ), + includes_url( 'css/buttons.min.css' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + YEAR_IN_SECONDS - 1000 ) . ' GMT' ) ), + includes_url( 'fonts/dashicons.woff2' ) => $this->build_response( 200, array( 'etag' => '"123456789"' ) ), + includes_url( 'images/media/video.png' ) => $this->build_response( 200, array() ), + 'conditional_304' => $this->build_response( 304 ), + ); + + $result = perflab_ffh_check_assets( + array( + includes_url( 'js/wp-embed.min.js' ), + includes_url( 'css/buttons.min.css' ), + includes_url( 'fonts/dashicons.woff2' ), + includes_url( 'images/media/video.png' ), + ) + ); + + $this->assertEquals( 'recommended', $result['final_status'] ); + $this->assertStringContainsString( 'max-age below threshold (actual:', $result['details'][0]['reason'] ); + $this->assertStringContainsString( 'expires below threshold (actual:', $result['details'][1]['reason'] ); + $this->assertEquals( 'No far-future headers but conditionally cached', $result['details'][2]['reason'] ); + $this->assertEquals( 'No far-future headers and no conditional caching', $result['details'][3]['reason'] ); + } + + /** + * Test that the filter `perflab_ffh_assets_to_check` and `perflab_far_future_headers_threshold` are working as expected. + * + * @covers ::perflab_ffh_check_assets + */ + public function test_filters(): void { + add_filter( + 'perflab_ffh_assets_to_check', + static function ( $assets ) { + $assets[] = includes_url( 'images/blank.gif' ); + return $assets; + } + ); + + add_filter( + 'perflab_far_future_headers_threshold', + static function () { + return 1000; + } + ); + + $this->mocked_responses = array( + includes_url( 'js/wp-embed.min.js' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . 1500 ) ), + includes_url( 'css/buttons.min.css' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . 500 ) ), + includes_url( 'fonts/dashicons.woff2' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + 1500 ) . ' GMT' ) ), + includes_url( 'images/media/video.png' ) => $this->build_response( 200, array( 'expires' => gmdate( 'D, d M Y H:i:s', time() + 500 ) . ' GMT' ) ), + includes_url( 'images/blank.gif' ) => $this->build_response( 200, array( 'cache-control' => 'max-age=' . ( 500 ) ) ), + ); + + $result = perflab_ffh_check_assets( + array( + includes_url( 'js/wp-embed.min.js' ), + includes_url( 'css/buttons.min.css' ), + includes_url( 'fonts/dashicons.woff2' ), + includes_url( 'images/media/video.png' ), + includes_url( 'images/blank.gif' ), + ) + ); + + $this->assertEquals( 'recommended', $result['final_status'] ); + $this->assertStringContainsString( 'max-age below threshold (actual:', $result['details'][0]['reason'] ); + $this->assertStringContainsString( 'expires below threshold (actual:', $result['details'][1]['reason'] ); + $this->assertStringContainsString( 'max-age below threshold (actual:', $result['details'][2]['reason'] ); + } + + /** + * Test that when no assets are passed, the status is "good". + * + * @covers ::perflab_ffh_check_assets + */ + public function test_when_no_assets(): void { + $this->mocked_responses = array(); + + $result = perflab_ffh_check_assets( array() ); + + $this->assertEquals( 'good', $result['final_status'] ); + $this->assertEmpty( $result['details'] ); + } + + /** + * Mock HTTP requests for assets to simulate different responses. + * + * @param bool $response A preemptive return value of an HTTP request. Default false. + * @param array $args Request arguments. + * @param string $url The request URL. + * @return array Mocked response. + */ + public function mock_http_requests( bool $response, array $args, string $url ): array { + // If conditional headers used in second request, simulate a 304 response. + if ( isset( $this->mocked_responses['conditional_304'] ) && ( isset( $args['headers']['If-None-Match'] ) || isset( $args['headers']['If-Modified-Since'] ) ) ) { + return $this->mocked_responses['conditional_304']; + } + + if ( isset( $this->mocked_responses[ $url ] ) ) { + return $this->mocked_responses[ $url ]; + } + + // If no specific mock set, default to a generic success with no caching. + return $this->build_response( 200 ); + } + + /** + * Helper method to build a mock HTTP response. + * + * @param int $status_code HTTP status code. + * @param array $headers HTTP headers. + * @return array{response: array{code: int, message: string}, headers: WpOrg\Requests\Utility\CaseInsensitiveDictionary} + */ + protected function build_response( int $status_code = 200, array $headers = array() ): array { + return array( + 'response' => array( + 'code' => $status_code, + 'message' => '', + ), + 'headers' => new WpOrg\Requests\Utility\CaseInsensitiveDictionary( $headers ), + ); + } +}