PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Score / ScoreModule.php

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

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