PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 / modules / Score / ScoreModule.php

ScoreModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.2, at includes/modules/Score/ScoreModule.php

1,119 lines 37.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Score — external performance scores (PageSpeed Insights / GTmetrix)
4 * on the dashboard (issue #47).
5 *
6 * Free tier. Users judge a caching plugin by its PSI or GTmetrix score
7 * whether or not the plugin shows one, so the number belongs next to the
8 * internal TTFB benchmark instead of one browser tab away.
9 *
10 * The module owns settings, REST and CLI; the measuring lives in
11 * \XSpeed\Score. Runs are stored in the shape Pro's Pagespeed engine
12 * already returns, so a Pro audit and a Free audit are the same kind of
13 * row and the history stays a single series — Pro augments this rather
14 * than starting a second one.
15 *
16 * Outbound HTTP is opt-in: nothing here calls out unless the module is
17 * enabled AND a person presses Test (or runs the command). No schedule,
18 * no background call. Disclosed in readme.txt "External services".
19 *
20 * @package XSpeed
21 */
22
23 declare(strict_types=1);
24
25 namespace XSpeed\Modules\Score;
26
27 defined( 'ABSPATH' ) || exit;
28
29 use XSpeed\Module;
30 use XSpeed\Modules\Mcp\Mcp_Hub;
31 use XSpeed\Modules\Mcp\Mcp_Pairing;
32 use XSpeed\Scan;
33 use XSpeed\Score;
34 use XSpeed\Settings_Manager;
35
36 final class ScoreModule extends Module {
37
38 public const SLUG = 'score';
39 public const TIER = self::TIER_FREE;
40 public const VERSION = '1.1.0';
41
42 /**
43 * No On/Off state (#425).
44 *
45 * The default reports the `enabled` setting, which put an "Off" pill on a
46 * panel whose Test button works regardless — the press is the consent and
47 * flips the setting itself. A run-on-demand panel has no meaningful
48 * on/off, exactly like Health; the setting stays as the internal gate for
49 * non-press callers (optimize runs, GTmetrix polling), it just is not a
50 * state this panel wears.
51 */
52 public function is_active(): ?bool {
53 return null;
54 }
55
56 public function ui_metadata(): array {
57 return array(
58 'label' => __( 'Speed Test', 'xspeed' ),
59 'icon' => 'Gauge',
60 // Provider-neutral: the panel runs whichever provider the site has
61 // configured (PageSpeed Insights by default, no API key needed).
62 // The Hub-run test has its own copy and is gated behind
63 // hub_speed_test_enabled(), so this line must not promise it.
64 'description' => __( 'Run a PageSpeed Insights or GTmetrix audit from the dashboard and keep the history next to your TTFB benchmark.', 'xspeed' ),
65 'custom_panel' => 'ScorePanel',
66 );
67 }
68
69 public function settings_schema(): array {
70 return array(
71 'enabled' => array(
72 'type' => 'bool',
73 'default' => false,
74 // Meaningful for the surfaces it still reaches (REST schema,
75 // CLI settings, a wp-config override): it names what the
76 // value permits, not a switch nobody sees.
77 'label' => __( 'Allow speed tests', 'xspeed' ),
78 // Off by default, but pressing Test IS the consent: the first
79 // run turns this on rather than refusing (#425). What it
80 // still guards is everything that is NOT a Test press — an
81 // optimize run measuring its own effect, for instance.
82 // Switch it off (REST/CLI) and nothing contacts a provider.
83 'description' => __( 'Turns on automatically the first time you run a speed test — the button press is the consent. Switch it off to stop every feature, including optimize runs, from contacting a score provider.', 'xspeed' ),
84 // No dashboard control: the Test press manages it, and a
85 // visible switch that gates a button elsewhere was the
86 // confusion #425 removed. Hidden fields are skipped by the
87 // panel renderer and by settings search.
88 'hidden' => true,
89 ),
90 'provider' => array(
91 'type' => 'enum',
92 'default' => 'psi',
93 'options' => array( 'psi', 'gtmetrix' ),
94 'option_labels' => array(
95 'psi' => 'PageSpeed Insights',
96 'gtmetrix' => 'GTmetrix',
97 ),
98 'label' => __( 'Provider', 'xspeed' ),
99 'description' => __( 'PageSpeed Insights works without an API key. GTmetrix requires one.', 'xspeed' ),
100 ),
101 'psi_api_key' => array(
102 'type' => 'secret',
103 'default' => '',
104 'label' => __( 'PageSpeed API key (optional)', 'xspeed' ),
105 'description' => __( 'Only needed if you hit Google\'s anonymous rate limit. Free from cloud.google.com.', 'xspeed' ),
106 // Rendered as a trailing "Check the documentation" link —
107 // descriptions themselves are plain text (#111).
108 'doc_url' => 'https://xspeedcache.com/docs/pagespeed-insights-integration/',
109 'dependsOn' => array(
110 'field' => 'provider',
111 'value' => 'psi',
112 ),
113 ),
114 'gtmetrix_api_key' => array(
115 'type' => 'secret',
116 'default' => '',
117 'label' => __( 'GTmetrix API key', 'xspeed' ),
118 'description' => __( 'Required — GTmetrix has no anonymous mode. Found in your GTmetrix account settings.', 'xspeed' ),
119 'dependsOn' => array(
120 'field' => 'provider',
121 'value' => 'gtmetrix',
122 ),
123 ),
124 'test_url' => array(
125 'type' => 'url',
126 'default' => '',
127 'label' => __( 'URL to test', 'xspeed' ),
128 'description' => __( 'Leave empty to test your home page.', 'xspeed' ),
129 ),
130 'default_strategy' => array(
131 'type' => 'enum',
132 'default' => 'mobile',
133 'options' => array( 'mobile', 'desktop' ),
134 'label' => __( 'Strategy', 'xspeed' ),
135 'description' => __( 'PageSpeed Insights only. Mobile is what Google ranks on.', 'xspeed' ),
136 'dependsOn' => array(
137 'field' => 'provider',
138 'value' => 'psi',
139 ),
140 ),
141 );
142 }
143
144 /**
145 * Encrypt the pre-1.1.0 plaintext API keys on upgrade — psi_api_key /
146 * gtmetrix_api_key became `secret`-typed fields (encrypted at rest).
147 * Idempotent. (#115)
148 */
149 public function migrations(): array {
150 return array(
151 '1.1.0' => static function ( array $opts ): array {
152 foreach ( array( 'psi_api_key', 'gtmetrix_api_key' ) as $key ) {
153 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
154 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
155 }
156 }
157 return $opts;
158 },
159 );
160 }
161
162 public function rest_routes(): array {
163 return array_merge(
164 parent::rest_routes(),
165 array(
166 array(
167 'path' => '/run',
168 'methods' => 'POST',
169 'callback' => array( $this, 'rest_run' ),
170 'feature' => self::SLUG,
171 ),
172 array(
173 'path' => '/status',
174 'methods' => 'GET',
175 'callback' => array( $this, 'rest_status' ),
176 ),
177 array(
178 'path' => '/history',
179 'methods' => 'GET',
180 'callback' => array( $this, 'rest_history' ),
181 ),
182 /*
183 * Hub-powered GTmetrix. Deliberately NOT gated on the
184 * `enabled` setting the way /run and /status are.
185 *
186 * That gate exists because /run makes an outbound call on the
187 * SITE's behalf with the SITE owner's key — it is the promise
188 * in readme.txt that nothing is sent to a third party until
189 * you say so. This path is different: the site talks only to
190 * the Hub it has already been deliberately connected to, and
191 * the Hub owns the GTmetrix account. Requiring the toggle as
192 * well would keep the five-step funnel this feature exists to
193 * remove.
194 */
195 array(
196 'path' => '/hub-test',
197 'methods' => 'POST',
198 'callback' => array( $this, 'rest_hub_test' ),
199 ),
200 array(
201 'path' => '/hub-status',
202 'methods' => 'GET',
203 'callback' => array( $this, 'rest_hub_status' ),
204 ),
205 /*
206 * xSpeed Scan. Like the Hub routes above, deliberately NOT
207 * gated on the `enabled` setting: that toggle guards sending
208 * the site's URL to Google or GTmetrix with the SITE owner's
209 * own API key. The scan engine is our own service, needs no
210 * key, and the user starts it by pressing Scan — the consent
211 * is the press, and requiring a settings toggle first would
212 * reinstate exactly the funnel this feature removes.
213 *
214 * What the UI MUST NOT skip is telling the user whether the
215 * resulting report is public; see Scan::private_supported().
216 */
217 array(
218 'path' => '/scan',
219 'methods' => 'POST',
220 'callback' => array( $this, 'rest_scan_start' ),
221 ),
222 array(
223 'path' => '/scan-status',
224 'methods' => 'GET',
225 'callback' => array( $this, 'rest_scan_status' ),
226 ),
227 )
228 );
229 }
230
231 /**
232 * Start an xSpeed Scan.
233 *
234 * Answers as soon as the engine accepts the run — a scan takes 20-60s,
235 * so holding the request open would trip every proxy between here and
236 * the browser. The caller polls /scan-status.
237 *
238 * @return \WP_REST_Response|\WP_Error
239 */
240 public function rest_scan_start( \WP_REST_Request $request ) {
241 // One at a time. Without this a double-click spends two runs against
242 // the engine's rate limit and leaves two pending markers racing.
243 $pending = Scan::pending();
244 if ( null !== $pending ) {
245 return rest_ensure_response(
246 array(
247 'status' => 'running',
248 'scan_id' => $pending['scan_id'],
249 'step' => '',
250 )
251 );
252 }
253
254 $started = Scan::start(
255 (string) ( $request->get_param( 'url' ) ?? '' ),
256 (bool) $request->get_param( 'fresh' )
257 );
258 if ( is_wp_error( $started ) ) {
259 return $started;
260 }
261
262 return rest_ensure_response(
263 array(
264 'status' => 'running',
265 'scan_id' => $started['scan_id'],
266 'report_url' => $started['report_url'],
267 'cached' => $started['cached'],
268 )
269 );
270 }
271
272 /**
273 * Poll the in-flight scan, or report the last completed one.
274 *
275 * Always 200: "nothing has been scanned yet" is an empty state, not an
276 * error, and the dashboard renders it on first paint.
277 *
278 * @return \WP_REST_Response|\WP_Error
279 */
280 public function rest_scan_status() {
281 $pending = Scan::pending();
282
283 if ( null !== $pending ) {
284 $polled = Scan::poll( (string) $pending['scan_id'] );
285 if ( is_wp_error( $polled ) ) {
286 return $polled;
287 }
288 if ( isset( $polled['status'] ) && 'running' === $polled['status'] ) {
289 return rest_ensure_response(
290 array(
291 'status' => 'running',
292 'scan_id' => $polled['scan_id'],
293 'step' => $polled['step'],
294 'latest' => Scan::latest(),
295 'visibility' => Scan::visibility(),
296 'private_supported' => Scan::private_supported(),
297 )
298 );
299 }
300 return rest_ensure_response(
301 array(
302 'status' => 'complete',
303 'latest' => $polled,
304 'visibility' => Scan::visibility(),
305 'private_supported' => Scan::private_supported(),
306 )
307 );
308 }
309
310 return rest_ensure_response(
311 array(
312 'status' => 'idle',
313 'latest' => Scan::latest(),
314 'visibility' => Scan::visibility(),
315 'private_supported' => Scan::private_supported(),
316 )
317 );
318 }
319
320 /**
321 * Start a Hub-run GTmetrix test.
322 *
323 * Returns the Hub's payload on success. On failure the WP_Error code is
324 * the Hub's own stable code (site_not_verified, gtmetrix_quota_exceeded,
325 * …) so the UI can respond specifically, and the HTTP status is carried
326 * through rather than flattened to 500.
327 *
328 * @return \WP_REST_Response|\WP_Error
329 */
330 public function rest_hub_test() {
331 if ( ! self::hub_speed_test_enabled() ) {
332 return new \WP_Error(
333 'xspeed_hub_speed_test_disabled',
334 __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
335 array( 'status' => 403 )
336 );
337 }
338
339 $result = Mcp_Hub::gtmetrix_test();
340 return $this->hub_result( $result );
341 }
342
343 /**
344 * Is the Hub-run speed test available on this site?
345 *
346 * Off by default: the feature is built and merged, but the hosted runner
347 * behind it is not being announced yet, and a button that offers a test we
348 * are not ready to serve is worse than no button. The panel already has a
349 * complete story without it — PageSpeed Insights runs with no API key, and
350 * a site with its own provider key is unaffected either way.
351 *
352 * `rest_hub_status()` reports `feature_disabled`, which is deliberately NOT
353 * in the UI's CONNECTABLE_REASONS allowlist, so both surfaces (the Overview
354 * card and the panel's primary action) fall back to the PageSpeed flow
355 * rather than offering a Connect prompt that leads nowhere.
356 *
357 * Flip with `add_filter( 'xspeed_hub_speed_test_enabled', '__return_true' );`
358 * — one line, no code change, for when the runner is announced.
359 */
360 public static function hub_speed_test_enabled(): bool {
361 /**
362 * Whether the Hub-run speed test is offered in the dashboard.
363 *
364 * @param bool $enabled Default false.
365 */
366 return (bool) apply_filters( 'xspeed_hub_speed_test_enabled', false );
367 }
368
369 /**
370 * Recent Hub-run tests + the remaining monthly allowance.
371 *
372 * @return \WP_REST_Response|\WP_Error
373 */
374 public function rest_hub_status() {
375 if ( ! self::hub_speed_test_enabled() ) {
376 return rest_ensure_response(
377 array(
378 'available' => false,
379 'reason' => 'feature_disabled',
380 'message' => __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
381 'local' => false,
382 )
383 );
384 }
385
386 $result = Mcp_Hub::gtmetrix_runs();
387 if ( is_wp_error( $result ) ) {
388 // A status read must never look like a hard failure — the panel
389 // still has a history to draw. Report "unavailable" and let the UI
390 // hide the Hub affordance rather than showing an error banner.
391 return rest_ensure_response(
392 array(
393 'available' => false,
394 'reason' => $result->get_error_code(),
395 'message' => $result->get_error_message(),
396 'local' => Mcp_Hub::is_local_site(),
397 )
398 );
399 }
400
401 $result['available'] = true;
402 $result['local'] = Mcp_Hub::is_local_site();
403 return rest_ensure_response( $result );
404 }
405
406 /**
407 * Turn an Mcp_Hub result into a REST response, preserving the status code.
408 *
409 * @param array<string,mixed>|\WP_Error $result Hub call result.
410 * @return \WP_REST_Response|\WP_Error
411 */
412 private function hub_result( $result ) {
413 if ( ! is_wp_error( $result ) ) {
414 return rest_ensure_response( $result );
415 }
416
417 $data = $result->get_error_data();
418 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502;
419 // Anything below 400 would be nonsense on an error path.
420 if ( $status < 400 ) {
421 $status = 502;
422 }
423 $data = is_array( $data ) ? $data : array();
424 $data['status'] = $status;
425 $data['local'] = Mcp_Hub::is_local_site();
426
427 return new \WP_Error( $result->get_error_code(), $result->get_error_message(), $data );
428 }
429
430 /**
431 * Start (or, for PSI, complete) an audit.
432 *
433 * POST, never GET: this spends someone else's rate limit and takes up
434 * to a minute. A GET would be prefetched by a browser.
435 */
436 public function rest_run( \WP_REST_Request $request ) {
437 $opts = $this->consent_by_running( Settings_Manager::get( self::SLUG ) );
438
439 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
440 if ( '' === $url ) {
441 return new \WP_Error(
442 'xspeed_score_no_url',
443 __( 'No URL to test.', 'xspeed' ),
444 array( 'status' => 400 )
445 );
446 }
447
448 $provider = (string) ( $request->get_param( 'provider' ) ?: $opts['provider'] );
449
450 if ( 'gtmetrix' === $provider ) {
451 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
452 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
453 }
454
455 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
456 $api_key = (string) $opts['psi_api_key'];
457
458 // No key of their own → run it through the Hub when this site is
459 // connected. The Hub holds a real Google key, so this is the path
460 // that does NOT die on the shared anonymous quota (#426). When the
461 // Hub can't take it, fall through to the anonymous direct call —
462 // worse odds, but exactly what the plugin did before.
463 if ( '' === trim( $api_key ) ) {
464 $via_hub = $this->start_psi_via_hub( $url, $strategy );
465 if ( null !== $via_hub ) {
466 return $via_hub;
467 }
468 }
469
470 return rest_ensure_response( Score::run_psi( $url, $strategy, $api_key ) );
471 }
472
473 /**
474 * Record the Test press as the opt-in (#425).
475 *
476 * The five-step funnel — find the toggle, enable it, come back, press
477 * Test — existed to make the outbound call opt-in. The press already is
478 * the opt-in: it is an explicit, authenticated request to contact a
479 * provider right now. So a run no longer refuses when the toggle is off;
480 * it turns the toggle on and proceeds, and the toggle keeps its real job
481 * of gating everything that is NOT a Test press (optimize runs measuring
482 * their own effect, GTmetrix polling).
483 *
484 * @param array<string,mixed> $opts Current module settings.
485 * @return array<string,mixed> Settings with `enabled` true.
486 */
487 private function consent_by_running( array $opts ): array {
488 if ( empty( $opts['enabled'] ) ) {
489 Settings_Manager::update( self::SLUG, array( 'enabled' => true ) );
490 $opts['enabled'] = true;
491 }
492 return $opts;
493 }
494
495 /**
496 * Start a keyless PSI audit through the Hub, or null when the Hub cannot
497 * take it and the caller should fall back to the direct anonymous call.
498 *
499 * Null — fall back — only for "the Hub was never an option here": not
500 * connected, PSI not configured on it, or unreachable. A real refusal
501 * (rate-limited, a run already active) is surfaced, because retrying it
502 * anonymously would spend the shared quota to report a worse error.
503 *
504 * The Hub audits the site's HOME page, so a custom test URL also skips
505 * this path rather than silently testing a different page than asked.
506 *
507 * @return \WP_REST_Response|\WP_Error|null
508 */
509 private function start_psi_via_hub( string $url, string $strategy ) {
510 if ( untrailingslashit( $url ) !== untrailingslashit( (string) home_url( '/' ) ) ) {
511 return null;
512 }
513
514 $result = Mcp_Hub::psi_test( $strategy );
515
516 if ( is_wp_error( $result ) ) {
517 if ( in_array( $result->get_error_code(), array( 'not_connected', 'psi_not_configured', 'hub_unreachable' ), true ) ) {
518 return null;
519 }
520 // A 401/403 means the pairing is dead (revoked, detached, stale
521 // token) — for THIS feature that is the same as not connected,
522 // not an error the Test button should wear.
523 $data = $result->get_error_data();
524 if ( is_array( $data ) && in_array( (int) ( $data['status'] ?? 0 ), array( 401, 403 ), true ) ) {
525 return null;
526 }
527 return $this->hub_result( $result );
528 }
529
530 $run_id = isset( $result['run']['id'] ) ? (string) $result['run']['id'] : '';
531
532 // No run id means nothing can ever be polled — writing a marker here
533 // would orphan it (may_poll() rejects an empty test_id before the
534 // staleness check, so it would never expire either). A 202 without an
535 // id is a malformed Hub response; say so rather than pretend a test
536 // is pending.
537 if ( '' === $run_id ) {
538 return new \WP_Error(
539 'hub_error',
540 __( 'xSpeed Hub accepted the test but returned no run id. Please try again.', 'xspeed' ),
541 array( 'status' => 502 )
542 );
543 }
544
545 // The Hub answers 202 before the audit runs; the result arrives via
546 // the same pending/poll machinery GTmetrix already uses.
547 update_option(
548 Score::PENDING_OPTION,
549 array(
550 'test_id' => $run_id,
551 'url' => $url,
552 'started' => time(),
553 'provider' => 'hub-psi',
554 ),
555 false
556 );
557
558 return rest_ensure_response(
559 array(
560 'ok' => true,
561 'provider' => 'psi',
562 'source' => 'hub',
563 'state' => 'queued',
564 'test_id' => $run_id,
565 'url' => $url,
566 'strategy' => $strategy,
567 'pending' => true,
568 )
569 );
570 }
571
572 /**
573 * Poll an in-flight Hub-run PSI audit.
574 *
575 * psi_runs() has already copied any finished run into the local history,
576 * so resolving here is: find our run, see whether it is still going, and
577 * drop the marker the moment it is not.
578 *
579 * @param array<string,mixed> $pending The stored pending marker.
580 * @return array<string,mixed>|\WP_Error
581 */
582 private function poll_hub_psi( array $pending ) {
583 $result = Mcp_Hub::psi_runs();
584 if ( is_wp_error( $result ) ) {
585 return $result;
586 }
587
588 $mine = null;
589 foreach ( (array) ( $result['runs'] ?? array() ) as $run ) {
590 if ( is_array( $run ) && (string) ( $run['id'] ?? '' ) === (string) $pending['test_id'] ) {
591 $mine = $run;
592 break;
593 }
594 }
595
596 $state = is_array( $mine ) ? (string) ( $mine['status'] ?? '' ) : '';
597
598 if ( 'queued' === $state || 'running' === $state ) {
599 return array(
600 'ok' => true,
601 'provider' => 'psi',
602 'source' => 'hub',
603 'state' => $state,
604 'test_id' => (string) $pending['test_id'],
605 'pending' => true,
606 );
607 }
608
609 // Terminal — done, error, or the Hub no longer lists it at all.
610 delete_option( Score::PENDING_OPTION );
611
612 if ( 'error' === $state ) {
613 $row = array(
614 'ok' => false,
615 'provider' => 'psi',
616 'source' => 'hub',
617 'state' => 'error',
618 'pending' => false,
619 'error' => (string) ( $mine['error'] ?? __( 'The audit did not produce a result.', 'xspeed' ) ),
620 );
621 Score::record(
622 array(
623 'ok' => false,
624 'provider' => 'psi',
625 'ts' => time(),
626 'url' => (string) ( $pending['url'] ?? '' ),
627 'strategy' => 'mobile',
628 'score' => null,
629 'metrics' => array(),
630 'issues' => array(),
631 'error' => $row['error'],
632 'source' => 'hub',
633 )
634 );
635 return $row;
636 }
637
638 return array(
639 'ok' => true,
640 'provider' => 'psi',
641 'source' => 'hub',
642 'state' => 'completed',
643 'pending' => false,
644 );
645 }
646
647 /**
648 * Poll an in-flight GTmetrix test.
649 *
650 * GET because it is a read of state we already started — the browser
651 * calls it every few seconds while a test is queued.
652 */
653 public function rest_status() {
654 $opts = Settings_Manager::get( self::SLUG );
655 $pending = get_option( Score::PENDING_OPTION, array() );
656
657 // Same opt-in gate as the rest of the module. Without it, `status` —
658 // which is also the CLI's DEFAULT action — polled GTmetrix with the
659 // feature switched off and no API key, which falsified readme.txt's
660 // promise that nothing is sent while it is off. (A Hub-run test polls
661 // only the Hub the site is deliberately connected to.)
662 if ( ! $this->may_poll( $opts, $pending ) ) {
663 return rest_ensure_response(
664 array(
665 'pending' => false,
666 'state' => 'idle',
667 'latest' => Score::latest(),
668 )
669 );
670 }
671
672 $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
673 ? $this->poll_hub_psi( $pending )
674 : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
675 if ( is_wp_error( $polled ) ) {
676 return $polled;
677 }
678
679 return rest_ensure_response(
680 array_merge(
681 $polled,
682 array( 'latest' => Score::latest() )
683 )
684 );
685 }
686
687 public function rest_history() {
688 return rest_ensure_response(
689 array(
690 'runs' => Score::history(),
691 'latest' => Score::latest(),
692 'thresholds' => Score::thresholds(),
693 )
694 );
695 }
696
697 /**
698 * May we contact anyone to poll the in-flight test?
699 *
700 * The pending marker must be real and not stale — a marker with no expiry
701 * turned one failed start into a permanent poll loop against a third
702 * party. Beyond that, who we may poll depends on who ran the test: a
703 * GTmetrix test needs the feature on and an API key (there is no
704 * anonymous GTmetrix); a Hub-run test needs only the Hub connection the
705 * site already has — the Hub is not a third party the toggle guards.
706 *
707 * @param array<string,mixed> $opts Module settings.
708 * @param mixed $pending The stored pending marker.
709 */
710 private function may_poll( array $opts, $pending ): bool {
711 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
712 return false;
713 }
714 // A test that hasn't resolved within the window is not going to;
715 // drop the marker rather than poll it forever.
716 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
717 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
718 delete_option( Score::PENDING_OPTION );
719 return false;
720 }
721 if ( 'hub-psi' === ( $pending['provider'] ?? '' ) ) {
722 return '' !== Mcp_Pairing::site_token();
723 }
724 return ! empty( $opts['enabled'] ) && '' !== trim( (string) $opts['gtmetrix_api_key'] );
725 }
726
727 /**
728 * Fall back to the home page when no URL is configured — testing "my
729 * site" is what almost everyone means.
730 *
731 * @param array<string,mixed> $opts Module settings.
732 */
733 private function resolve_url( string $requested, array $opts ): string {
734 foreach ( array( $requested, (string) ( $opts['test_url'] ?? '' ) ) as $candidate ) {
735 $candidate = trim( $candidate );
736 if ( '' !== $candidate ) {
737 return $candidate;
738 }
739 }
740 return function_exists( 'home_url' ) ? (string) home_url( '/' ) : '';
741 }
742
743 public function cli_commands(): array {
744 return array(
745 array(
746 'name' => 'xspeed score',
747 'callback' => array( $this, 'cli_handler' ),
748 'shortdesc' => 'External performance scores: `run` a PageSpeed Insights / GTmetrix audit (use --target=<url>, not --url, which WP-CLI reserves), `status` for an in-flight GTmetrix test, `history` for past runs.',
749 'ai_hint' => 'Measure real-world performance with an external audit (PageSpeed Insights / GTmetrix), or read past scores. Use to answer "did that change actually help" with a measured before/after instead of an assumption, and to get Core Web Vitals for a specific page. `run` starts an audit (pass --target=<url> for a page other than the home page; --url is reserved by WP-CLI), `status` polls an in-flight GTmetrix test, `history` returns previous runs. An audit takes up to a couple of minutes, so say so before starting one.',
750 'synopsis' => array(
751 array(
752 'type' => 'positional',
753 'name' => 'action',
754 'options' => array( 'status', 'run', 'history', 'hub-test' ),
755 'optional' => true,
756 ),
757 array(
758 'type' => 'assoc',
759 // NOT `--url`: that is a WP-CLI *global* parameter, so
760 // the value never reaches this handler and the flag is
761 // silently ignored.
762 'name' => 'target',
763 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
764 'optional' => true,
765 ),
766 array(
767 'type' => 'assoc',
768 'name' => 'strategy',
769 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
770 'optional' => true,
771 ),
772 array(
773 'type' => 'assoc',
774 'name' => 'provider',
775 'description' => 'psi (default) or gtmetrix.',
776 'optional' => true,
777 ),
778 ),
779 ),
780 /*
781 * A SEPARATE command, not another action on `score`, because the
782 * two produce different numbers. A scan score is the xSpeed
783 * rubric (four weighted dimensions, ~20 checks); a score run is a
784 * raw provider score. Folding them together would invite exactly
785 * the comparison the two scales cannot support.
786 */
787 array(
788 'name' => 'xspeed scan',
789 'callback' => array( $this, 'cli_scan_handler' ),
790 'shortdesc' => 'xSpeed Scan: `run` a full graded site report, `status` to poll one or read the last, `fixes` for what to do next.',
791 'ai_hint' => 'Run a full xSpeed Scan and read the graded result. This is BROADER than `xspeed score`: it grades four weighted dimensions (speed, delivery, assets, platform) over ~20 checks and returns what to fix ranked by how many points each recovers, which a raw PageSpeed score cannot tell you. The scan score and a Lighthouse score are DIFFERENT SCALES - never present them as the same number or compare one to the other. `run` starts a scan (20-60s; poll with `status`), `fixes` lists the ranked remediations. Reports are published to a public per-host list unless the site is connected to the Hub, so say so before starting one.',
792 'synopsis' => array(
793 array(
794 'type' => 'positional',
795 'name' => 'action',
796 'options' => array( 'run', 'status', 'fixes' ),
797 'optional' => true,
798 ),
799 array(
800 'type' => 'assoc',
801 // NOT `--url`, which WP-CLI reserves as a global.
802 'name' => 'target',
803 'description' => 'URL to scan. Defaults to the home page.',
804 'optional' => true,
805 ),
806 array(
807 'type' => 'flag',
808 'name' => 'fresh',
809 'description' => 'Force a new scan instead of reusing a recent cached report.',
810 'optional' => true,
811 ),
812 array(
813 'type' => 'flag',
814 'name' => 'wait',
815 'description' => 'Poll until the scan finishes instead of returning immediately.',
816 'optional' => true,
817 ),
818 ),
819 ),
820 );
821 }
822
823 /**
824 * `wp xspeed scan [run|status|fixes]`
825 *
826 * @param array<int,string> $args Positional.
827 * @param array<string,string> $assoc_args Flags.
828 */
829 public function cli_scan_handler( array $args, array $assoc_args ): void {
830 $action = $args[0] ?? 'status';
831
832 if ( 'fixes' === $action ) {
833 $latest = Scan::latest();
834 if ( null === $latest || empty( $latest['fixes'] ) ) {
835 \WP_CLI::log( 'No scan result yet. Run `wp xspeed scan run --wait` first.' );
836 return;
837 }
838 $rows = array();
839 foreach ( $latest['fixes'] as $f ) {
840 $rows[] = array(
841 'check' => $f['id'] . ' ' . $f['name'],
842 'status' => $f['status'],
843 'recoverable' => $f['recoverable'],
844 'evidence' => $f['evidence'],
845 );
846 }
847 \WP_CLI\Utils\format_items( 'table', $rows, array( 'check', 'status', 'recoverable', 'evidence' ) );
848 return;
849 }
850
851 if ( 'run' === $action ) {
852 // Said before the call, not after: on an unconnected site this
853 // publishes a report about the user's site to a public list.
854 // Name the remedy too -- connecting the Hub is what makes a
855 // report unlisted, and a warning without it leaves no move.
856 if ( 'private' !== Scan::visibility() || ! Scan::private_supported() ) {
857 \WP_CLI::warning(
858 'This report will be publicly visible at xspeedcache.com, listed under your domain. '
859 . 'Connect xSpeed Hub from the dashboard to keep your reports unlisted.'
860 );
861 }
862
863 $started = Scan::start(
864 (string) ( $assoc_args['target'] ?? '' ),
865 ! empty( $assoc_args['fresh'] )
866 );
867 if ( is_wp_error( $started ) ) {
868 \WP_CLI::error( $started->get_error_message() );
869 return;
870 }
871 \WP_CLI::log( 'Scan started: ' . $started['scan_id'] );
872 \WP_CLI::log( 'Report: ' . $started['report_url'] );
873
874 if ( empty( $assoc_args['wait'] ) ) {
875 \WP_CLI::log( 'Poll with `wp xspeed scan status`.' );
876 return;
877 }
878
879 // A scan is 20-60s; cap the wait so a stuck engine cannot hang
880 // a CLI session indefinitely.
881 $deadline = time() + 180;
882 while ( time() < $deadline ) {
883 sleep( 5 );
884 $polled = Scan::poll( (string) $started['scan_id'] );
885 if ( is_wp_error( $polled ) ) {
886 \WP_CLI::error( $polled->get_error_message() );
887 return;
888 }
889 if ( ! isset( $polled['status'] ) || 'running' !== $polled['status'] ) {
890 self::cli_print_scan( $polled );
891 return;
892 }
893 \WP_CLI::log( ' ' . ( $polled['step'] ?: 'working' ) . '...' );
894 }
895 \WP_CLI::warning( 'Still running. Poll with `wp xspeed scan status`.' );
896 return;
897 }
898
899 // status
900 $pending = Scan::pending();
901 if ( null !== $pending ) {
902 $polled = Scan::poll( (string) $pending['scan_id'] );
903 if ( is_wp_error( $polled ) ) {
904 \WP_CLI::error( $polled->get_error_message() );
905 return;
906 }
907 if ( isset( $polled['status'] ) && 'running' === $polled['status'] ) {
908 \WP_CLI::log( 'Running: ' . ( $polled['step'] ?: 'working' ) );
909 return;
910 }
911 self::cli_print_scan( $polled );
912 return;
913 }
914
915 $latest = Scan::latest();
916 if ( null === $latest ) {
917 \WP_CLI::log( 'No scan yet. Run `wp xspeed scan run --wait`.' );
918 return;
919 }
920 self::cli_print_scan( $latest );
921 }
922
923 /** Shared rendering for a completed scan. */
924 private static function cli_print_scan( array $r ): void {
925 \WP_CLI::log(
926 sprintf(
927 'Score %s/100 grade %s (%s)%s',
928 null === $r['score'] ? '-' : $r['score'],
929 $r['grade'] ?: '-',
930 $r['level_name'] ?: '-',
931 ! empty( $r['partial'] ) ? ' [partial scan]' : ''
932 )
933 );
934 foreach ( (array) ( $r['dimensions'] ?? array() ) as $key => $d ) {
935 \WP_CLI::log( sprintf( ' %-9s %3s/100 (%s of %s pts)', $key, $d['score'] ?? '-', $d['earned'] ?? '-', $d['weight'] ?? '-' ) );
936 }
937 $lh = $r['measured']['lighthouse'] ?? null;
938 $lhd = $r['measured']['lighthouse_desktop'] ?? null;
939 if ( null !== $lh || null !== $lhd ) {
940 // Both strategies: the engine measures both and they diverge
941 // widely, so reporting only mobile states the harsher number as
942 // though it were the whole picture. Labelled, and never as "the
943 // score": different scale.
944 \WP_CLI::log(
945 sprintf(
946 'Lighthouse: mobile %s, desktop %s - a different scale, one check inside the score above.',
947 null === $lh ? '-' : $lh . '/100',
948 null === $lhd ? '-' : $lhd . '/100'
949 )
950 );
951 }
952 \WP_CLI::log( 'Report: ' . ( $r['report_url'] ?? '' ) );
953 }
954
955 public function cli_handler( array $args, array $assoc ): void {
956 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
957 $opts = Settings_Manager::get( self::SLUG );
958
959 /*
960 * Hub-powered GTmetrix. Handled before the `enabled` gate below: this
961 * path does not use the site's own key or settings at all — it asks
962 * the Hub the site is already connected to, and the Hub owns the
963 * GTmetrix account.
964 */
965 if ( 'hub-test' === $action ) {
966 if ( ! self::hub_speed_test_enabled() ) {
967 \WP_CLI::error( 'Hub-run speed tests are not enabled on this site.' );
968 return;
969 }
970 $result = Mcp_Hub::gtmetrix_test();
971 if ( is_wp_error( $result ) ) {
972 \WP_CLI::error( $result->get_error_message() );
973 return;
974 }
975 $quota = isset( $result['quota'] ) && is_array( $result['quota'] ) ? $result['quota'] : array();
976 \WP_CLI::success(
977 sprintf(
978 'Test started. %s left this month.',
979 isset( $quota['remaining'] ) ? (string) (int) $quota['remaining'] : '?'
980 )
981 );
982 \WP_CLI::log( 'Results appear in the dashboard when the test finishes (about a minute).' );
983 return;
984 }
985
986 if ( 'history' === $action ) {
987 $runs = Score::history();
988 if ( empty( $runs ) ) {
989 \WP_CLI::log( 'No runs recorded yet.' );
990 return;
991 }
992 foreach ( $runs as $run ) {
993 \WP_CLI::log(
994 sprintf(
995 '%s %-9s %-8s %s',
996 gmdate( 'Y-m-d H:i', (int) $run['ts'] ),
997 (string) ( $run['provider'] ?? '' ),
998 empty( $run['ok'] ) ? 'FAILED' : ( null === ( $run['score'] ?? null ) ? 'no score' : $run['score'] . '/100' ),
999 empty( $run['ok'] ) ? (string) ( $run['error'] ?? '' ) : (string) ( $run['url'] ?? '' )
1000 )
1001 );
1002 }
1003 return;
1004 }
1005
1006 if ( 'run' === $action ) {
1007 // Running the command IS the opt-in — same consent rule as the
1008 // dashboard's Test button (#425).
1009 $opts = $this->consent_by_running( $opts );
1010
1011 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
1012 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
1013
1014 if ( 'gtmetrix' === $provider ) {
1015 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
1016 if ( is_wp_error( $started ) ) {
1017 \WP_CLI::error( $started->get_error_message() );
1018 return;
1019 }
1020 \WP_CLI::success( sprintf( 'GTmetrix test queued (id %s). Poll with: wp xspeed score status', (string) ( $started['test_id'] ?? '?' ) ) );
1021 return;
1022 }
1023
1024 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
1025 $api_key = (string) $opts['psi_api_key'];
1026
1027 // No key → prefer the Hub, same ladder as rest_run() (#426).
1028 if ( '' === trim( $api_key ) ) {
1029 $via_hub = $this->start_psi_via_hub( $url, $strategy );
1030 if ( $via_hub instanceof \WP_Error ) {
1031 \WP_CLI::error( $via_hub->get_error_message() );
1032 return;
1033 }
1034 if ( null !== $via_hub ) {
1035 \WP_CLI::success( 'PageSpeed audit started via xSpeed Hub. Poll with: wp xspeed score status' );
1036 return;
1037 }
1038 }
1039
1040 $run = Score::run_psi( $url, $strategy, $api_key );
1041
1042 if ( empty( $run['ok'] ) ) {
1043 \WP_CLI::error( (string) $run['error'] );
1044 return;
1045 }
1046 \WP_CLI::success(
1047 sprintf(
1048 '%s (%s): %s',
1049 $url,
1050 $strategy,
1051 null === $run['score'] ? 'no score returned' : $run['score'] . '/100'
1052 )
1053 );
1054 $this->print_metrics( is_array( $run['metrics'] ) ? $run['metrics'] : array() );
1055 return;
1056 }
1057
1058 // status
1059 $pending = get_option( Score::PENDING_OPTION, array() );
1060 if ( $this->may_poll( $opts, $pending ) ) {
1061 $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
1062 ? $this->poll_hub_psi( $pending )
1063 : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
1064 if ( is_wp_error( $polled ) ) {
1065 \WP_CLI::error( $polled->get_error_message() );
1066 return;
1067 }
1068 if ( ! empty( $polled['pending'] ) ) {
1069 \WP_CLI::log(
1070 sprintf(
1071 '%s test %s is %s.',
1072 'hub-psi' === ( $pending['provider'] ?? '' ) ? 'PageSpeed (Hub)' : 'GTmetrix',
1073 (string) $pending['test_id'],
1074 (string) ( $polled['state'] ?? 'running' )
1075 )
1076 );
1077 return;
1078 }
1079 }
1080
1081 \WP_CLI::log( 'enabled ' . ( empty( $opts['enabled'] ) ? 'no' : 'yes' ) );
1082 \WP_CLI::log( 'provider ' . (string) $opts['provider'] );
1083
1084 $latest = Score::latest();
1085 if ( null === $latest ) {
1086 \WP_CLI::log( 'No successful run yet. Run one with: wp xspeed score run' );
1087 return;
1088 }
1089 \WP_CLI::log(
1090 sprintf(
1091 'latest %s — %s (%s)',
1092 null === $latest['score'] ? 'no score' : $latest['score'] . '/100',
1093 gmdate( 'Y-m-d H:i', (int) $latest['ts'] ),
1094 (string) $latest['provider']
1095 )
1096 );
1097 $this->print_metrics( is_array( $latest['metrics'] ) ? $latest['metrics'] : array() );
1098 }
1099
1100 /**
1101 * @param array<string,mixed> $metrics Metric name → value.
1102 */
1103 private function print_metrics( array $metrics ): void {
1104 foreach ( $metrics as $name => $value ) {
1105 if ( null === $value ) {
1106 continue;
1107 }
1108 \WP_CLI::log(
1109 sprintf(
1110 ' %-5s %-10s %s',
1111 strtoupper( (string) $name ),
1112 'cls' === $name ? (string) round( (float) $value, 3 ) : (int) $value . 'ms',
1113 Score::rate( (string) $name, (float) $value )
1114 )
1115 );
1116 }
1117 }
1118 }
1119