PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-optimize-verifier.php

class-optimize-verifier.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-optimize-verifier.php

206 lines 7.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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