| 1 |
<?php |
| 2 |
/** |
| 3 |
* Optimize verifier — did that change break the page? |
| 4 |
* |
| 5 |
* @package XSpeed |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace XSpeed; |
| 9 |
|
| 10 |
defined( 'ABSPATH' ) || exit; |
| 11 |
|
| 12 |
/** |
| 13 |
* Capture what a page looks like, then decide whether a later fetch of it is |
| 14 |
* still healthy. |
| 15 |
* |
| 16 |
* The autopilot's whole claim to being safe rests here. Applying settings is |
| 17 |
* easy; knowing you have not just served a blank page to every visitor is the |
| 18 |
* hard part, and it is the part a human doing this by hand actually performs — |
| 19 |
* they look at the site. |
| 20 |
* |
| 21 |
* ## What this can and cannot see |
| 22 |
* |
| 23 |
* This runs in PHP, over the HTML a request returns. It catches the failures |
| 24 |
* that show up in markup: a fatal, a truncated document, a stylesheet that |
| 25 |
* vanished, a page that collapsed to a fraction of its size. |
| 26 |
* |
| 27 |
* It does NOT execute JavaScript, so it cannot see a page that arrives intact |
| 28 |
* and then breaks in the browser. That failure is real and has happened here: |
| 29 |
* removing jQuery Migrate produced `jQuery.Deferred exception: e.indexOf is not |
| 30 |
* a function` on a page whose HTML was complete and the right size. Every |
| 31 |
* assertion below would have passed it. |
| 32 |
* |
| 33 |
* That is why `Optimize_Plan` puts anything with an invisible failure mode in |
| 34 |
* the AGGRESSIVE tier rather than trusting this class to catch it. The check |
| 35 |
* and the classification are two halves of one safety story; neither is |
| 36 |
* sufficient alone. A JS-executing check belongs in the E2E layer, which has a |
| 37 |
* real browser — see #210. |
| 38 |
* |
| 39 |
* Comparison is always against a baseline captured BEFORE the run, never |
| 40 |
* against absolute thresholds: "this page has 3 stylesheets" is meaningless, |
| 41 |
* "this page had 54 stylesheets and now has 0" is a broken site. |
| 42 |
* |
| 43 |
* @since 1.2.0 |
| 44 |
*/ |
| 45 |
final class Optimize_Verifier { |
| 46 |
|
| 47 |
/** |
| 48 |
* How far the HTML may shrink or grow before it is treated as broken. |
| 49 |
* |
| 50 |
* Wide on purpose. Minification legitimately removes a chunk of a page, |
| 51 |
* and combining rewrites a headful of tags — neither is damage. What this |
| 52 |
* catches is the catastrophic case: a fatal that truncates the document, or |
| 53 |
* a blank page, both of which collapse the size far past any optimization. |
| 54 |
*/ |
| 55 |
private const SIZE_TOLERANCE = 0.5; |
| 56 |
|
| 57 |
/** |
| 58 |
* Fetch a page and reduce it to the handful of facts worth comparing. |
| 59 |
* |
| 60 |
* Requested ANONYMOUSLY and uncached. A logged-in request hits the |
| 61 |
* drop-in's bailout and never sees the cached path, so it would verify a |
| 62 |
* page no visitor is served; a cached response would verify the page as it |
| 63 |
* was BEFORE the change, which is worse than not checking at all. |
| 64 |
* |
| 65 |
* @param string $url Absolute URL to sample. |
| 66 |
* @return array<string,mixed>|\WP_Error |
| 67 |
*/ |
| 68 |
public static function sample( string $url ) { |
| 69 |
$url = esc_url_raw( $url ); |
| 70 |
if ( '' === $url ) { |
| 71 |
return new \WP_Error( 'xspeed_verify_url', __( 'A URL is required.', 'xspeed' ) ); |
| 72 |
} |
| 73 |
|
| 74 |
// Cache-buster: without it a static-cached HIT returns the pre-change |
| 75 |
// page and every check passes against stale HTML. |
| 76 |
$bust = add_query_arg( 'xspeed_verify', (string) time(), $url ); |
| 77 |
|
| 78 |
$t0 = microtime( true ); |
| 79 |
$resp = wp_remote_get( |
| 80 |
$bust, |
| 81 |
array( |
| 82 |
'timeout' => 20, |
| 83 |
'redirection' => 3, |
| 84 |
'sslverify' => false, |
| 85 |
'headers' => array( 'Cache-Control' => 'no-cache' ), |
| 86 |
// A real browser UA: some hosts and firewalls serve a |
| 87 |
// challenge page to unknown agents, which would read as the |
| 88 |
// site being broken. |
| 89 |
'user-agent' => 'Mozilla/5.0 (compatible; xSpeed-Verifier/1.0)', |
| 90 |
) |
| 91 |
); |
| 92 |
|
| 93 |
if ( is_wp_error( $resp ) ) { |
| 94 |
return $resp; |
| 95 |
} |
| 96 |
|
| 97 |
$body = (string) wp_remote_retrieve_body( $resp ); |
| 98 |
|
| 99 |
return array( |
| 100 |
'status' => (int) wp_remote_retrieve_response_code( $resp ), |
| 101 |
'bytes' => strlen( $body ), |
| 102 |
'complete' => (bool) preg_match( '#</body\s*>#i', $body ), |
| 103 |
'stylesheets' => self::count_stylesheets( $body ), |
| 104 |
'scripts' => (int) preg_match_all( '#<script\b[^>]*\bsrc=#i', $body ), |
| 105 |
'title' => self::extract_title( $body ), |
| 106 |
// Wall-clock for the whole request, in ms. The cache-buster above |
| 107 |
// makes every sample an uncached render, so successive samples |
| 108 |
// measure the same thing and their medians are comparable — this |
| 109 |
// is what Optimizer's per-step regression check reads. (#310) |
| 110 |
'elapsed_ms' => ( microtime( true ) - $t0 ) * 1000, |
| 111 |
); |
| 112 |
} |
| 113 |
|
| 114 |
/** |
| 115 |
* Count real stylesheet links. |
| 116 |
* |
| 117 |
* `<noscript>` blocks are stripped first. The async-CSS pattern emits a |
| 118 |
* no-JS fallback `<link>` beside every deferred one, so counting naively |
| 119 |
* doubles the total and makes a healthy page look like it grew — a |
| 120 |
* mistake worth guarding against in code, having been made once in |
| 121 |
* analysis. |
| 122 |
* |
| 123 |
* @param string $html Page HTML. |
| 124 |
*/ |
| 125 |
private static function count_stylesheets( string $html ): int { |
| 126 |
$stripped = (string) preg_replace( '#<noscript\b[^>]*>.*?</noscript\s*>#is', '', $html ); |
| 127 |
return (int) preg_match_all( '#<link\b[^>]*\brel=["\']?stylesheet#i', $stripped ); |
| 128 |
} |
| 129 |
|
| 130 |
/** |
| 131 |
* @param string $html Page HTML. |
| 132 |
*/ |
| 133 |
private static function extract_title( string $html ): string { |
| 134 |
if ( preg_match( '#<title\b[^>]*>(.*?)</title\s*>#is', $html, $m ) ) { |
| 135 |
return trim( wp_strip_all_tags( $m[1] ) ); |
| 136 |
} |
| 137 |
return ''; |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Compare a fresh sample against the baseline. |
| 142 |
* |
| 143 |
* Returns every failure rather than the first, so a report can say what |
| 144 |
* actually went wrong instead of "verification failed". |
| 145 |
* |
| 146 |
* Pure — no I/O, unit-tested. |
| 147 |
* |
| 148 |
* @param array<string,mixed> $baseline Sample taken before the run. |
| 149 |
* @param array<string,mixed> $current Sample taken after a change. |
| 150 |
* @return array{ok:bool,failures:string[]} |
| 151 |
*/ |
| 152 |
public static function compare( array $baseline, array $current ): array { |
| 153 |
$failures = array(); |
| 154 |
|
| 155 |
if ( 200 !== (int) ( $current['status'] ?? 0 ) ) { |
| 156 |
$failures[] = sprintf( |
| 157 |
/* translators: %d: HTTP status code */ |
| 158 |
__( 'The page returned HTTP %d.', 'xspeed' ), |
| 159 |
(int) ( $current['status'] ?? 0 ) |
| 160 |
); |
| 161 |
// Nothing below is meaningful once the response itself failed. |
| 162 |
return array( |
| 163 |
'ok' => false, |
| 164 |
'failures' => $failures, |
| 165 |
); |
| 166 |
} |
| 167 |
|
| 168 |
if ( empty( $current['complete'] ) ) { |
| 169 |
$failures[] = __( 'The page stopped part-way through — no closing </body>, which usually means a PHP fatal.', 'xspeed' ); |
| 170 |
} |
| 171 |
|
| 172 |
$before = (int) ( $baseline['bytes'] ?? 0 ); |
| 173 |
$after = (int) ( $current['bytes'] ?? 0 ); |
| 174 |
if ( $before > 0 ) { |
| 175 |
$ratio = $after / $before; |
| 176 |
if ( $ratio < ( 1 - self::SIZE_TOLERANCE ) || $ratio > ( 1 + self::SIZE_TOLERANCE ) ) { |
| 177 |
$failures[] = sprintf( |
| 178 |
/* translators: 1: before size in bytes, 2: after size in bytes */ |
| 179 |
__( 'The page size changed from %1$d to %2$d bytes — too far to be optimization.', 'xspeed' ), |
| 180 |
$before, |
| 181 |
$after |
| 182 |
); |
| 183 |
} |
| 184 |
} |
| 185 |
|
| 186 |
// Zero is the signal, not a decrease: combining legitimately takes 54 |
| 187 |
// stylesheets down to 3. Losing them ALL is a page with no styling. |
| 188 |
if ( (int) ( $baseline['stylesheets'] ?? 0 ) > 0 && 0 === (int) ( $current['stylesheets'] ?? 0 ) ) { |
| 189 |
$failures[] = __( 'Every stylesheet disappeared — the page would render unstyled.', 'xspeed' ); |
| 190 |
} |
| 191 |
if ( (int) ( $baseline['scripts'] ?? 0 ) > 0 && 0 === (int) ( $current['scripts'] ?? 0 ) ) { |
| 192 |
$failures[] = __( 'Every script disappeared.', 'xspeed' ); |
| 193 |
} |
| 194 |
|
| 195 |
$before_title = (string) ( $baseline['title'] ?? '' ); |
| 196 |
if ( '' !== $before_title && $before_title !== (string) ( $current['title'] ?? '' ) ) { |
| 197 |
$failures[] = __( 'The page title changed — this may be an error page rather than the site.', 'xspeed' ); |
| 198 |
} |
| 199 |
|
| 200 |
return array( |
| 201 |
'ok' => array() === $failures, |
| 202 |
'failures' => $failures, |
| 203 |
); |
| 204 |
} |
| 205 |
} |
| 206 |
|