PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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.2.0, at includes/class-optimize-verifier.php

200 lines 7.1 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 $resp = wp_remote_get(
79 $bust,
80 array(
81 'timeout' => 20,
82 'redirection' => 3,
83 'sslverify' => false,
84 'headers' => array( 'Cache-Control' => 'no-cache' ),
85 // A real browser UA: some hosts and firewalls serve a
86 // challenge page to unknown agents, which would read as the
87 // site being broken.
88 'user-agent' => 'Mozilla/5.0 (compatible; xSpeed-Verifier/1.0)',
89 )
90 );
91
92 if ( is_wp_error( $resp ) ) {
93 return $resp;
94 }
95
96 $body = (string) wp_remote_retrieve_body( $resp );
97
98 return array(
99 'status' => (int) wp_remote_retrieve_response_code( $resp ),
100 'bytes' => strlen( $body ),
101 'complete' => (bool) preg_match( '#</body\s*>#i', $body ),
102 'stylesheets' => self::count_stylesheets( $body ),
103 'scripts' => (int) preg_match_all( '#<script\b[^>]*\bsrc=#i', $body ),
104 'title' => self::extract_title( $body ),
105 );
106 }
107
108 /**
109 * Count real stylesheet links.
110 *
111 * `<noscript>` blocks are stripped first. The async-CSS pattern emits a
112 * no-JS fallback `<link>` beside every deferred one, so counting naively
113 * doubles the total and makes a healthy page look like it grew — a
114 * mistake worth guarding against in code, having been made once in
115 * analysis.
116 *
117 * @param string $html Page HTML.
118 */
119 private static function count_stylesheets( string $html ): int {
120 $stripped = (string) preg_replace( '#<noscript\b[^>]*>.*?</noscript\s*>#is', '', $html );
121 return (int) preg_match_all( '#<link\b[^>]*\brel=["\']?stylesheet#i', $stripped );
122 }
123
124 /**
125 * @param string $html Page HTML.
126 */
127 private static function extract_title( string $html ): string {
128 if ( preg_match( '#<title\b[^>]*>(.*?)</title\s*>#is', $html, $m ) ) {
129 return trim( wp_strip_all_tags( $m[1] ) );
130 }
131 return '';
132 }
133
134 /**
135 * Compare a fresh sample against the baseline.
136 *
137 * Returns every failure rather than the first, so a report can say what
138 * actually went wrong instead of "verification failed".
139 *
140 * Pure — no I/O, unit-tested.
141 *
142 * @param array<string,mixed> $baseline Sample taken before the run.
143 * @param array<string,mixed> $current Sample taken after a change.
144 * @return array{ok:bool,failures:string[]}
145 */
146 public static function compare( array $baseline, array $current ): array {
147 $failures = array();
148
149 if ( 200 !== (int) ( $current['status'] ?? 0 ) ) {
150 $failures[] = sprintf(
151 /* translators: %d: HTTP status code */
152 __( 'The page returned HTTP %d.', 'xspeed' ),
153 (int) ( $current['status'] ?? 0 )
154 );
155 // Nothing below is meaningful once the response itself failed.
156 return array(
157 'ok' => false,
158 'failures' => $failures,
159 );
160 }
161
162 if ( empty( $current['complete'] ) ) {
163 $failures[] = __( 'The page stopped part-way through — no closing </body>, which usually means a PHP fatal.', 'xspeed' );
164 }
165
166 $before = (int) ( $baseline['bytes'] ?? 0 );
167 $after = (int) ( $current['bytes'] ?? 0 );
168 if ( $before > 0 ) {
169 $ratio = $after / $before;
170 if ( $ratio < ( 1 - self::SIZE_TOLERANCE ) || $ratio > ( 1 + self::SIZE_TOLERANCE ) ) {
171 $failures[] = sprintf(
172 /* translators: 1: before size in bytes, 2: after size in bytes */
173 __( 'The page size changed from %1$d to %2$d bytes — too far to be optimization.', 'xspeed' ),
174 $before,
175 $after
176 );
177 }
178 }
179
180 // Zero is the signal, not a decrease: combining legitimately takes 54
181 // stylesheets down to 3. Losing them ALL is a page with no styling.
182 if ( (int) ( $baseline['stylesheets'] ?? 0 ) > 0 && 0 === (int) ( $current['stylesheets'] ?? 0 ) ) {
183 $failures[] = __( 'Every stylesheet disappeared — the page would render unstyled.', 'xspeed' );
184 }
185 if ( (int) ( $baseline['scripts'] ?? 0 ) > 0 && 0 === (int) ( $current['scripts'] ?? 0 ) ) {
186 $failures[] = __( 'Every script disappeared.', 'xspeed' );
187 }
188
189 $before_title = (string) ( $baseline['title'] ?? '' );
190 if ( '' !== $before_title && $before_title !== (string) ( $current['title'] ?? '' ) ) {
191 $failures[] = __( 'The page title changed — this may be an error page rather than the site.', 'xspeed' );
192 }
193
194 return array(
195 'ok' => array() === $failures,
196 'failures' => $failures,
197 );
198 }
199 }
200