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
← All changes | includes/modules/Score/ScoreModule.php +570 -61 1.1.4 → 1.3.7 View file →
@@ -27,8 +27,10 @@
27 27 defined( 'ABSPATH' ) || exit;
28 28
29 29 use XSpeed\Module;
30 30 use XSpeed\Modules\Mcp\Mcp_Hub;
31 +use XSpeed\Modules\Mcp\Mcp_Pairing;
32 +use XSpeed\Scan;
31 33 use XSpeed\Score;
32 34 use XSpeed\Settings_Manager;
33 35
34 36 final class ScoreModule extends Module {
@@ -36,18 +38,33 @@
36 38 public const SLUG = 'score';
37 39 public const TIER = self::TIER_FREE;
38 40 public const VERSION = '1.1.0';
39 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 +
40 56 public function ui_metadata(): array {
41 57 return array(
42 - 'label' => 'Speed Test',
58 + 'label' => __( 'Speed Test', 'xspeed' ),
43 59 'icon' => 'Gauge',
44 60 // Provider-neutral: the panel runs whichever provider the site has
45 61 // configured (PageSpeed Insights by default, no API key needed).
46 62 // The Hub-run test has its own copy and is gated behind
47 63 // hub_speed_test_enabled(), so this line must not promise it.
48 - 'description' => 'Run a PageSpeed Insights or GTmetrix audit from the dashboard and keep the history next to your TTFB benchmark.',
64 + 'description' => __( 'Run a PageSpeed Insights or GTmetrix test and keep a history of the scores.', 'xspeed' ),
49 65 'custom_panel' => 'ScorePanel',
66 + 'group' => 'insights',
50 67 );
51 68 }
52 69
53 70 public function settings_schema(): array {
@@ -54,12 +71,23 @@
54 71 return array(
55 72 'enabled' => array(
56 73 'type' => 'bool',
57 74 'default' => false,
58 - 'label' => 'Enable external scores',
59 - // Off by default and stated plainly: this is the only part
60 - // of the plugin that talks to a third party on your behalf.
61 - 'description' => 'Lets you run a PageSpeed Insights or GTmetrix audit from this dashboard. Nothing is sent anywhere until you press Test.',
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,
62 90 ),
63 91 'provider' => array(
64 92 'type' => 'enum',
65 93 'default' => 'psi',
@@ -67,20 +95,20 @@
67 95 'option_labels' => array(
68 96 'psi' => 'PageSpeed Insights',
69 97 'gtmetrix' => 'GTmetrix',
70 98 ),
71 - 'label' => 'Provider',
72 - 'description' => 'PageSpeed Insights works without an API key. GTmetrix requires one.',
73 - 'dependsOn' => array( 'field' => 'enabled' ),
99 + 'label' => __( 'Test provider', 'xspeed' ),
100 + 'description' => __( 'PageSpeed Insights works without an API key. GTmetrix needs one.', 'xspeed' ),
74 101 ),
75 102 'psi_api_key' => array(
76 103 'type' => 'secret',
77 104 'default' => '',
78 - 'label' => 'PageSpeed API key (optional)',
79 - 'description' => 'Only needed if you hit Google\'s anonymous rate limit. Free from cloud.google.com.',
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' ),
80 107 // Rendered as a trailing "Check the documentation" link —
81 108 // descriptions themselves are plain text (#111).
82 109 'doc_url' => 'https://xspeedcache.com/docs/pagespeed-insights-integration/',
110 + 'advanced' => true,
83 111 'dependsOn' => array(
84 112 'field' => 'provider',
85 113 'value' => 'psi',
86 114 ),
@@ -87,10 +115,10 @@
87 115 ),
88 116 'gtmetrix_api_key' => array(
89 117 'type' => 'secret',
90 118 'default' => '',
91 - 'label' => 'GTmetrix API key',
92 - 'description' => 'Required — GTmetrix has no anonymous mode. Found in your GTmetrix account settings.',
119 + 'label' => __( 'GTmetrix API key', 'xspeed' ),
120 + 'description' => __( 'GTmetrix does not run tests without a key. Find it in your GTmetrix account settings.', 'xspeed' ),
93 121 'dependsOn' => array(
94 122 'field' => 'provider',
95 123 'value' => 'gtmetrix',
96 124 ),
@@ -97,18 +125,17 @@
97 125 ),
98 126 'test_url' => array(
99 127 'type' => 'url',
100 128 'default' => '',
101 - 'label' => 'URL to test',
102 - 'description' => 'Leave empty to test your home page.',
103 - 'dependsOn' => array( 'field' => 'enabled' ),
129 + 'label' => __( 'URL to test', 'xspeed' ),
130 + 'description' => __( 'Leave empty to test your home page.', 'xspeed' ),
104 131 ),
105 132 'default_strategy' => array(
106 133 'type' => 'enum',
107 134 'default' => 'mobile',
108 135 'options' => array( 'mobile', 'desktop' ),
109 - 'label' => 'Strategy',
110 - 'description' => 'PageSpeed Insights only. Mobile is what Google ranks on.',
136 + 'label' => __( 'Device', 'xspeed' ),
137 + 'description' => __( 'Test as a phone or a desktop visitor. Google ranks sites on the mobile result.', 'xspeed' ),
111 138 'dependsOn' => array(
112 139 'field' => 'provider',
113 140 'value' => 'psi',
114 141 ),
@@ -176,13 +203,124 @@
176 203 'path' => '/hub-status',
177 204 'methods' => 'GET',
178 205 'callback' => array( $this, 'rest_hub_status' ),
179 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 + ),
180 229 )
181 230 );
182 231 }
183 232
184 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 + /**
185 323 * Start a Hub-run GTmetrix test.
186 324 *
187 325 * Returns the Hub's payload on success. On failure the WP_Error code is
188 326 * the Hub's own stable code (site_not_verified, gtmetrix_quota_exceeded,
@@ -297,18 +435,10 @@
297 435 * POST, never GET: this spends someone else's rate limit and takes up
298 436 * to a minute. A GET would be prefetched by a browser.
299 437 */
300 438 public function rest_run( \WP_REST_Request $request ) {
301 - $opts = Settings_Manager::get( self::SLUG );
439 + $opts = $this->consent_by_running( Settings_Manager::get( self::SLUG ) );
302 440
303 - if ( empty( $opts['enabled'] ) ) {
304 - return new \WP_Error(
305 - 'xspeed_score_disabled',
306 - __( 'External scores are turned off. Enable them first — this is the only feature that contacts a third party.', 'xspeed' ),
307 - array( 'status' => 409 )
308 - );
309 - }
310 -
311 441 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
312 442 if ( '' === $url ) {
313 443 return new \WP_Error(
314 444 'xspeed_score_no_url',
@@ -324,12 +454,200 @@
324 454 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
325 455 }
326 456
327 457 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
328 - return rest_ensure_response( Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] ) );
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 ) );
329 473 }
330 474
331 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 + /**
332 650 * Poll an in-flight GTmetrix test.
333 651 *
334 652 * GET because it is a read of state we already started — the browser
335 653 * calls it every few seconds while a test is queued.
@@ -337,12 +655,13 @@
337 655 public function rest_status() {
338 656 $opts = Settings_Manager::get( self::SLUG );
339 657 $pending = get_option( Score::PENDING_OPTION, array() );
340 658
341 - // Same opt-in gate as rest_run(). Without it, `status` — which is
342 - // also the CLI's DEFAULT action — polled GTmetrix with the feature
343 - // switched off and no API key, which falsified readme.txt's promise
344 - // that nothing is sent while it is off.
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.)
345 664 if ( ! $this->may_poll( $opts, $pending ) ) {
346 665 return rest_ensure_response(
347 666 array(
348 667 'pending' => false,
@@ -351,19 +670,11 @@
351 670 )
352 671 );
353 672 }
354 673
355 - if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
356 - return rest_ensure_response(
357 - array(
358 - 'pending' => false,
359 - 'state' => 'idle',
360 - 'latest' => Score::latest(),
361 - )
362 - );
363 - }
364 -
365 - $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
674 + $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
675 + ? $this->poll_hub_psi( $pending )
676 + : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
366 677 if ( is_wp_error( $polled ) ) {
367 678 return $polled;
368 679 }
369 680
@@ -385,33 +696,35 @@
385 696 );
386 697 }
387 698
388 699 /**
389 - * May we contact GTmetrix to poll the in-flight test?
700 + * May we contact anyone to poll the in-flight test?
390 701 *
391 - * Three conditions, all necessary: the feature is on, an API key exists
392 - * (there is no anonymous GTmetrix), and the pending marker is real and
393 - * not stale. A marker with no expiry turned one failed start into a
394 - * permanent poll loop against a third party.
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.
395 708 *
396 709 * @param array<string,mixed> $opts Module settings.
397 710 * @param mixed $pending The stored pending marker.
398 711 */
399 712 private function may_poll( array $opts, $pending ): bool {
400 - if ( empty( $opts['enabled'] ) || '' === trim( (string) $opts['gtmetrix_api_key'] ) ) {
401 - return false;
402 - }
403 713 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
404 714 return false;
405 715 }
406 - // A GTmetrix test that hasn't resolved within the window is not going
407 - // to; drop the marker rather than poll it forever.
716 + // A test that hasn't resolved within the window is not going to;
717 + // drop the marker rather than poll it forever.
408 718 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
409 719 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
410 720 delete_option( Score::PENDING_OPTION );
411 721 return false;
412 722 }
413 - return true;
723 + if ( 'hub-psi' === ( $pending['provider'] ?? '' ) ) {
724 + return '' !== Mcp_Pairing::site_token();
725 + }
726 + return ! empty( $opts['enabled'] ) && '' !== trim( (string) $opts['gtmetrix_api_key'] );
414 727 }
415 728
416 729 /**
417 730 * Fall back to the home page when no URL is configured — testing "my
@@ -434,8 +747,9 @@
434 747 array(
435 748 'name' => 'xspeed score',
436 749 'callback' => array( $this, 'cli_handler' ),
437 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.',
438 752 'synopsis' => array(
439 753 array(
440 754 'type' => 'positional',
441 755 'name' => 'action',
@@ -464,11 +778,183 @@
464 778 'optional' => true,
465 779 ),
466 780 ),
467 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 + ),
468 822 );
469 823 }
470 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 +
471 957 public function cli_handler( array $args, array $assoc ): void {
472 958 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
473 959 $opts = Settings_Manager::get( self::SLUG );
474 960
@@ -519,12 +1005,11 @@
519 1005 return;
520 1006 }
521 1007
522 1008 if ( 'run' === $action ) {
523 - if ( empty( $opts['enabled'] ) ) {
524 - \WP_CLI::error( 'External scores are turned off. Enable the score module first — this is the only feature that contacts a third party.' );
525 - return;
526 - }
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 );
527 1012
528 1013 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
529 1014 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
530 1015
@@ -538,10 +1023,25 @@
538 1023 return;
539 1024 }
540 1025
541 1026 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
542 - $run = Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] );
1027 + $api_key = (string) $opts['psi_api_key'];
543 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 +
544 1044 if ( empty( $run['ok'] ) ) {
545 1045 \WP_CLI::error( (string) $run['error'] );
546 1046 return;
547 1047 }
@@ -559,15 +1059,24 @@
559 1059
560 1060 // status
561 1061 $pending = get_option( Score::PENDING_OPTION, array() );
562 1062 if ( $this->may_poll( $opts, $pending ) ) {
563 - $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
1063 + $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
1064 + ? $this->poll_hub_psi( $pending )
1065 + : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
564 1066 if ( is_wp_error( $polled ) ) {
565 1067 \WP_CLI::error( $polled->get_error_message() );
566 1068 return;
567 1069 }
568 1070 if ( ! empty( $polled['pending'] ) ) {
569 - \WP_CLI::log( sprintf( 'GTmetrix test %s is %s.', (string) $pending['test_id'], (string) ( $polled['state'] ?? 'running' ) ) );
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 + );
570 1079 return;
571 1080 }
572 1081 }
573 1082