PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 35 releases
xspeed / includes / modules / Score / ScoreModule.php

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

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