PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/modules/Score/ScoreModule.php +324 -72 1.2.4 → 1.4.2 View file →
@@ -27,8 +27,9 @@
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;
31 32 use XSpeed\Scan;
32 33 use XSpeed\Score;
33 34 use XSpeed\Settings_Manager;
34 35
@@ -37,18 +38,33 @@
37 38 public const SLUG = 'score';
38 39 public const TIER = self::TIER_FREE;
39 40 public const VERSION = '1.1.0';
40 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 +
41 56 public function ui_metadata(): array {
42 57 return array(
43 - 'label' => 'Speed Test',
58 + 'label' => __( 'Speed Test', 'xspeed' ),
44 59 'icon' => 'Gauge',
45 60 // Provider-neutral: the panel runs whichever provider the site has
46 61 // configured (PageSpeed Insights by default, no API key needed).
47 62 // The Hub-run test has its own copy and is gated behind
48 63 // hub_speed_test_enabled(), so this line must not promise it.
49 - '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' ),
50 65 'custom_panel' => 'ScorePanel',
66 + 'group' => 'insights',
51 67 );
52 68 }
53 69
54 70 public function settings_schema(): array {
@@ -55,12 +71,23 @@
55 71 return array(
56 72 'enabled' => array(
57 73 'type' => 'bool',
58 74 'default' => false,
59 - 'label' => 'Enable external scores',
60 - // Off by default and stated plainly: this is the only part
61 - // of the plugin that talks to a third party on your behalf.
62 - '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,
63 90 ),
64 91 'provider' => array(
65 92 'type' => 'enum',
66 93 'default' => 'psi',
@@ -68,20 +95,20 @@
68 95 'option_labels' => array(
69 96 'psi' => 'PageSpeed Insights',
70 97 'gtmetrix' => 'GTmetrix',
71 98 ),
72 - 'label' => 'Provider',
73 - 'description' => 'PageSpeed Insights works without an API key. GTmetrix requires one.',
74 - 'dependsOn' => array( 'field' => 'enabled' ),
99 + 'label' => __( 'Test provider', 'xspeed' ),
100 + 'description' => __( 'PageSpeed Insights works without an API key. GTmetrix needs one.', 'xspeed' ),
75 101 ),
76 102 'psi_api_key' => array(
77 103 'type' => 'secret',
78 104 'default' => '',
79 - 'label' => 'PageSpeed API key (optional)',
80 - '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' ),
81 107 // Rendered as a trailing "Check the documentation" link —
82 108 // descriptions themselves are plain text (#111).
83 109 'doc_url' => 'https://xspeedcache.com/docs/pagespeed-insights-integration/',
110 + 'advanced' => true,
84 111 'dependsOn' => array(
85 112 'field' => 'provider',
86 113 'value' => 'psi',
87 114 ),
@@ -88,10 +115,10 @@
88 115 ),
89 116 'gtmetrix_api_key' => array(
90 117 'type' => 'secret',
91 118 'default' => '',
92 - 'label' => 'GTmetrix API key',
93 - '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' ),
94 121 'dependsOn' => array(
95 122 'field' => 'provider',
96 123 'value' => 'gtmetrix',
97 124 ),
@@ -98,18 +125,17 @@
98 125 ),
99 126 'test_url' => array(
100 127 'type' => 'url',
101 128 'default' => '',
102 - 'label' => 'URL to test',
103 - 'description' => 'Leave empty to test your home page.',
104 - 'dependsOn' => array( 'field' => 'enabled' ),
129 + 'label' => __( 'URL to test', 'xspeed' ),
130 + 'description' => __( 'Leave empty to test your home page.', 'xspeed' ),
105 131 ),
106 132 'default_strategy' => array(
107 133 'type' => 'enum',
108 134 'default' => 'mobile',
109 135 'options' => array( 'mobile', 'desktop' ),
110 - 'label' => 'Strategy',
111 - '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' ),
112 138 'dependsOn' => array(
113 139 'field' => 'provider',
114 140 'value' => 'psi',
115 141 ),
@@ -228,9 +254,12 @@
228 254 }
229 255
230 256 $started = Scan::start(
231 257 (string) ( $request->get_param( 'url' ) ?? '' ),
232 - (bool) $request->get_param( 'fresh' )
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' )
233 262 );
234 263 if ( is_wp_error( $started ) ) {
235 264 return $started;
236 265 }
@@ -267,8 +296,9 @@
267 296 'status' => 'running',
268 297 'scan_id' => $polled['scan_id'],
269 298 'step' => $polled['step'],
270 299 'latest' => Scan::latest(),
300 + 'stored' => Scan::stored_state(),
271 301 'visibility' => Scan::visibility(),
272 302 'private_supported' => Scan::private_supported(),
273 303 )
274 304 );
@@ -276,8 +306,9 @@
276 306 return rest_ensure_response(
277 307 array(
278 308 'status' => 'complete',
279 309 'latest' => $polled,
310 + 'stored' => 'ok',
280 311 'visibility' => Scan::visibility(),
281 312 'private_supported' => Scan::private_supported(),
282 313 )
283 314 );
@@ -286,8 +317,9 @@
286 317 return rest_ensure_response(
287 318 array(
288 319 'status' => 'idle',
289 320 'latest' => Scan::latest(),
321 + 'stored' => Scan::stored_state(),
290 322 'visibility' => Scan::visibility(),
291 323 'private_supported' => Scan::private_supported(),
292 324 )
293 325 );
@@ -409,18 +441,10 @@
409 441 * POST, never GET: this spends someone else's rate limit and takes up
410 442 * to a minute. A GET would be prefetched by a browser.
411 443 */
412 444 public function rest_run( \WP_REST_Request $request ) {
413 - $opts = Settings_Manager::get( self::SLUG );
445 + $opts = $this->consent_by_running( Settings_Manager::get( self::SLUG ) );
414 446
415 - if ( empty( $opts['enabled'] ) ) {
416 - return new \WP_Error(
417 - 'xspeed_score_disabled',
418 - __( 'External scores are turned off. Enable them first — this is the only feature that contacts a third party.', 'xspeed' ),
419 - array( 'status' => 409 )
420 - );
421 - }
422 -
423 447 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
424 448 if ( '' === $url ) {
425 449 return new \WP_Error(
426 450 'xspeed_score_no_url',
@@ -436,12 +460,200 @@
436 460 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
437 461 }
438 462
439 463 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
440 - return rest_ensure_response( Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] ) );
464 + $api_key = (string) $opts['psi_api_key'];
465 +
466 + // No key of their own → run it through the Hub when this site is
467 + // connected. The Hub holds a real Google key, so this is the path
468 + // that does NOT die on the shared anonymous quota (#426). When the
469 + // Hub can't take it, fall through to the anonymous direct call —
470 + // worse odds, but exactly what the plugin did before.
471 + if ( '' === trim( $api_key ) ) {
472 + $via_hub = $this->start_psi_via_hub( $url, $strategy );
473 + if ( null !== $via_hub ) {
474 + return $via_hub;
475 + }
476 + }
477 +
478 + return rest_ensure_response( Score::run_psi( $url, $strategy, $api_key ) );
441 479 }
442 480
443 481 /**
482 + * Record the Test press as the opt-in (#425).
483 + *
484 + * The five-step funnel — find the toggle, enable it, come back, press
485 + * Test — existed to make the outbound call opt-in. The press already is
486 + * the opt-in: it is an explicit, authenticated request to contact a
487 + * provider right now. So a run no longer refuses when the toggle is off;
488 + * it turns the toggle on and proceeds, and the toggle keeps its real job
489 + * of gating everything that is NOT a Test press (optimize runs measuring
490 + * their own effect, GTmetrix polling).
491 + *
492 + * @param array<string,mixed> $opts Current module settings.
493 + * @return array<string,mixed> Settings with `enabled` true.
494 + */
495 + private function consent_by_running( array $opts ): array {
496 + if ( empty( $opts['enabled'] ) ) {
497 + Settings_Manager::update( self::SLUG, array( 'enabled' => true ) );
498 + $opts['enabled'] = true;
499 + }
500 + return $opts;
501 + }
502 +
503 + /**
504 + * Start a keyless PSI audit through the Hub, or null when the Hub cannot
505 + * take it and the caller should fall back to the direct anonymous call.
506 + *
507 + * Null — fall back — only for "the Hub was never an option here": not
508 + * connected, PSI not configured on it, or unreachable. A real refusal
509 + * (rate-limited, a run already active) is surfaced, because retrying it
510 + * anonymously would spend the shared quota to report a worse error.
511 + *
512 + * The Hub audits the site's HOME page, so a custom test URL also skips
513 + * this path rather than silently testing a different page than asked.
514 + *
515 + * @return \WP_REST_Response|\WP_Error|null
516 + */
517 + private function start_psi_via_hub( string $url, string $strategy ) {
518 + if ( untrailingslashit( $url ) !== untrailingslashit( (string) home_url( '/' ) ) ) {
519 + return null;
520 + }
521 +
522 + $result = Mcp_Hub::psi_test( $strategy );
523 +
524 + if ( is_wp_error( $result ) ) {
525 + if ( in_array( $result->get_error_code(), array( 'not_connected', 'psi_not_configured', 'hub_unreachable' ), true ) ) {
526 + return null;
527 + }
528 + // A 401/403 means the pairing is dead (revoked, detached, stale
529 + // token) — for THIS feature that is the same as not connected,
530 + // not an error the Test button should wear.
531 + $data = $result->get_error_data();
532 + if ( is_array( $data ) && in_array( (int) ( $data['status'] ?? 0 ), array( 401, 403 ), true ) ) {
533 + return null;
534 + }
535 + return $this->hub_result( $result );
536 + }
537 +
538 + $run_id = isset( $result['run']['id'] ) ? (string) $result['run']['id'] : '';
539 +
540 + // No run id means nothing can ever be polled — writing a marker here
541 + // would orphan it (may_poll() rejects an empty test_id before the
542 + // staleness check, so it would never expire either). A 202 without an
543 + // id is a malformed Hub response; say so rather than pretend a test
544 + // is pending.
545 + if ( '' === $run_id ) {
546 + return new \WP_Error(
547 + 'hub_error',
548 + __( 'xSpeed Hub accepted the test but returned no run id. Please try again.', 'xspeed' ),
549 + array( 'status' => 502 )
550 + );
551 + }
552 +
553 + // The Hub answers 202 before the audit runs; the result arrives via
554 + // the same pending/poll machinery GTmetrix already uses.
555 + update_option(
556 + Score::PENDING_OPTION,
557 + array(
558 + 'test_id' => $run_id,
559 + 'url' => $url,
560 + 'started' => time(),
561 + 'provider' => 'hub-psi',
562 + ),
563 + false
564 + );
565 +
566 + return rest_ensure_response(
567 + array(
568 + 'ok' => true,
569 + 'provider' => 'psi',
570 + 'source' => 'hub',
571 + 'state' => 'queued',
572 + 'test_id' => $run_id,
573 + 'url' => $url,
574 + 'strategy' => $strategy,
575 + 'pending' => true,
576 + )
577 + );
578 + }
579 +
580 + /**
581 + * Poll an in-flight Hub-run PSI audit.
582 + *
583 + * psi_runs() has already copied any finished run into the local history,
584 + * so resolving here is: find our run, see whether it is still going, and
585 + * drop the marker the moment it is not.
586 + *
587 + * @param array<string,mixed> $pending The stored pending marker.
588 + * @return array<string,mixed>|\WP_Error
589 + */
590 + private function poll_hub_psi( array $pending ) {
591 + $result = Mcp_Hub::psi_runs();
592 + if ( is_wp_error( $result ) ) {
593 + return $result;
594 + }
595 +
596 + $mine = null;
597 + foreach ( (array) ( $result['runs'] ?? array() ) as $run ) {
598 + if ( is_array( $run ) && (string) ( $run['id'] ?? '' ) === (string) $pending['test_id'] ) {
599 + $mine = $run;
600 + break;
601 + }
602 + }
603 +
604 + $state = is_array( $mine ) ? (string) ( $mine['status'] ?? '' ) : '';
605 +
606 + if ( 'queued' === $state || 'running' === $state ) {
607 + return array(
608 + 'ok' => true,
609 + 'provider' => 'psi',
610 + 'source' => 'hub',
611 + 'state' => $state,
612 + 'test_id' => (string) $pending['test_id'],
613 + 'pending' => true,
614 + );
615 + }
616 +
617 + // Terminal — done, error, or the Hub no longer lists it at all.
618 + delete_option( Score::PENDING_OPTION );
619 +
620 + if ( 'error' === $state ) {
621 + $row = array(
622 + 'ok' => false,
623 + 'provider' => 'psi',
624 + 'source' => 'hub',
625 + 'state' => 'error',
626 + 'pending' => false,
627 + 'error' => (string) ( $mine['error'] ?? __( 'The audit did not produce a result.', 'xspeed' ) ),
628 + );
629 + Score::record(
630 + array(
631 + 'ok' => false,
632 + 'provider' => 'psi',
633 + 'ts' => time(),
634 + 'url' => (string) ( $pending['url'] ?? '' ),
635 + 'strategy' => 'mobile',
636 + 'score' => null,
637 + 'metrics' => array(),
638 + 'issues' => array(),
639 + 'error' => $row['error'],
640 + 'source' => 'hub',
641 + )
642 + );
643 + return $row;
644 + }
645 +
646 + return array(
647 + 'ok' => true,
648 + 'provider' => 'psi',
649 + 'source' => 'hub',
650 + 'state' => 'completed',
651 + 'pending' => false,
652 + );
653 + }
654 +
655 + /**
444 656 * Poll an in-flight GTmetrix test.
445 657 *
446 658 * GET because it is a read of state we already started — the browser
447 659 * calls it every few seconds while a test is queued.
@@ -449,12 +661,13 @@
449 661 public function rest_status() {
450 662 $opts = Settings_Manager::get( self::SLUG );
451 663 $pending = get_option( Score::PENDING_OPTION, array() );
452 664
453 - // Same opt-in gate as rest_run(). Without it, `status` — which is
454 - // also the CLI's DEFAULT action — polled GTmetrix with the feature
455 - // switched off and no API key, which falsified readme.txt's promise
456 - // that nothing is sent while it is off.
665 + // Same opt-in gate as the rest of the module. Without it, `status` —
666 + // which is also the CLI's DEFAULT action — polled GTmetrix with the
667 + // feature switched off and no API key, which falsified readme.txt's
668 + // promise that nothing is sent while it is off. (A Hub-run test polls
669 + // only the Hub the site is deliberately connected to.)
457 670 if ( ! $this->may_poll( $opts, $pending ) ) {
458 671 return rest_ensure_response(
459 672 array(
460 673 'pending' => false,
@@ -463,19 +676,11 @@
463 676 )
464 677 );
465 678 }
466 679
467 - if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
468 - return rest_ensure_response(
469 - array(
470 - 'pending' => false,
471 - 'state' => 'idle',
472 - 'latest' => Score::latest(),
473 - )
474 - );
475 - }
476 -
477 - $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
680 + $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
681 + ? $this->poll_hub_psi( $pending )
682 + : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
478 683 if ( is_wp_error( $polled ) ) {
479 684 return $polled;
480 685 }
481 686
@@ -497,33 +702,35 @@
497 702 );
498 703 }
499 704
500 705 /**
501 - * May we contact GTmetrix to poll the in-flight test?
706 + * May we contact anyone to poll the in-flight test?
502 707 *
503 - * Three conditions, all necessary: the feature is on, an API key exists
504 - * (there is no anonymous GTmetrix), and the pending marker is real and
505 - * not stale. A marker with no expiry turned one failed start into a
506 - * permanent poll loop against a third party.
708 + * The pending marker must be real and not stale — a marker with no expiry
709 + * turned one failed start into a permanent poll loop against a third
710 + * party. Beyond that, who we may poll depends on who ran the test: a
711 + * GTmetrix test needs the feature on and an API key (there is no
712 + * anonymous GTmetrix); a Hub-run test needs only the Hub connection the
713 + * site already has — the Hub is not a third party the toggle guards.
507 714 *
508 715 * @param array<string,mixed> $opts Module settings.
509 716 * @param mixed $pending The stored pending marker.
510 717 */
511 718 private function may_poll( array $opts, $pending ): bool {
512 - if ( empty( $opts['enabled'] ) || '' === trim( (string) $opts['gtmetrix_api_key'] ) ) {
513 - return false;
514 - }
515 719 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
516 720 return false;
517 721 }
518 - // A GTmetrix test that hasn't resolved within the window is not going
519 - // to; drop the marker rather than poll it forever.
722 + // A test that hasn't resolved within the window is not going to;
723 + // drop the marker rather than poll it forever.
520 724 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
521 725 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
522 726 delete_option( Score::PENDING_OPTION );
523 727 return false;
524 728 }
525 - return true;
729 + if ( 'hub-psi' === ( $pending['provider'] ?? '' ) ) {
730 + return '' !== Mcp_Pairing::site_token();
731 + }
732 + return ! empty( $opts['enabled'] ) && '' !== trim( (string) $opts['gtmetrix_api_key'] );
526 733 }
527 734
528 735 /**
529 736 * Fall back to the home page when no URL is configured — testing "my
@@ -610,8 +817,15 @@
610 817 'description' => 'Force a new scan instead of reusing a recent cached report.',
611 818 'optional' => true,
612 819 ),
613 820 array(
821 + 'type' => 'assoc',
822 + 'name' => 'strategy',
823 + '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.',
824 + 'optional' => true,
825 + 'options' => array( 'both', 'desktop', 'mobile' ),
826 + ),
827 + array(
614 828 'type' => 'flag',
615 829 'name' => 'wait',
616 830 'description' => 'Poll until the scan finishes instead of returning immediately.',
617 831 'optional' => true,
@@ -662,9 +876,10 @@
662 876 }
663 877
664 878 $started = Scan::start(
665 879 (string) ( $assoc_args['target'] ?? '' ),
666 - ! empty( $assoc_args['fresh'] )
880 + ! empty( $assoc_args['fresh'] ),
881 + (string) ( $assoc_args['strategy'] ?? 'both' )
667 882 );
668 883 if ( is_wp_error( $started ) ) {
669 884 \WP_CLI::error( $started->get_error_message() );
670 885 return;
@@ -713,8 +928,12 @@
713 928 return;
714 929 }
715 930
716 931 $latest = Scan::latest();
932 + if ( null === $latest && 'unreadable' === Scan::stored_state() ) {
933 + \WP_CLI::log( 'The last scan report could not be read back from the database and was discarded. Run `wp xspeed scan run --wait` again.' );
934 + return;
935 + }
717 936 if ( null === $latest ) {
718 937 \WP_CLI::log( 'No scan yet. Run `wp xspeed scan run --wait`.' );
719 938 return;
720 939 }
@@ -734,20 +953,30 @@
734 953 );
735 954 foreach ( (array) ( $r['dimensions'] ?? array() ) as $key => $d ) {
736 955 \WP_CLI::log( sprintf( ' %-9s %3s/100 (%s of %s pts)', $key, $d['score'] ?? '-', $d['earned'] ?? '-', $d['weight'] ?? '-' ) );
737 956 }
738 - $lh = $r['measured']['lighthouse'] ?? null;
957 + // Read the PER-DEVICE fields, never `measured.lighthouse`: that one is
958 + // the graded run's score, which is desktop on a default scan, so
959 + // printing it as "mobile" reported desktop under a mobile label.
960 + $lhm = $r['measured']['lighthouse_mobile'] ?? null;
739 961 $lhd = $r['measured']['lighthouse_desktop'] ?? null;
740 - if ( null !== $lh || null !== $lhd ) {
741 - // Both strategies: the engine measures both and they diverge
742 - // widely, so reporting only mobile states the harsher number as
743 - // though it were the whole picture. Labelled, and never as "the
744 - // score": different scale.
962 + if ( null !== $lhm || null !== $lhd ) {
963 + // Name only the devices actually measured. A desktop-only scan
964 + // never ran mobile, and a dash there reads as "scored zero"
965 + // rather than "not run".
966 + $parts = array();
967 + if ( null !== $lhd ) {
968 + $parts[] = 'desktop ' . $lhd . '/100';
969 + }
970 + if ( null !== $lhm ) {
971 + $parts[] = 'mobile ' . $lhm . '/100';
972 + }
973 + $graded = (string) ( $r['device'] ?? '' );
745 974 \WP_CLI::log(
746 975 sprintf(
747 - 'Lighthouse: mobile %s, desktop %s - a different scale, one check inside the score above.',
748 - null === $lh ? '-' : $lh . '/100',
749 - null === $lhd ? '-' : $lhd . '/100'
976 + 'Lighthouse: %s - a different scale, one check inside the score above.%s',
977 + implode( ', ', $parts ),
978 + '' !== $graded ? ' Graded on ' . $graded . '.' : ''
750 979 )
751 980 );
752 981 }
753 982 \WP_CLI::log( 'Report: ' . ( $r['report_url'] ?? '' ) );
@@ -804,12 +1033,11 @@
804 1033 return;
805 1034 }
806 1035
807 1036 if ( 'run' === $action ) {
808 - if ( empty( $opts['enabled'] ) ) {
809 - \WP_CLI::error( 'External scores are turned off. Enable the score module first — this is the only feature that contacts a third party.' );
810 - return;
811 - }
1037 + // Running the command IS the opt-in — same consent rule as the
1038 + // dashboard's Test button (#425).
1039 + $opts = $this->consent_by_running( $opts );
812 1040
813 1041 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
814 1042 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
815 1043
@@ -823,10 +1051,25 @@
823 1051 return;
824 1052 }
825 1053
826 1054 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
827 - $run = Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] );
1055 + $api_key = (string) $opts['psi_api_key'];
828 1056
1057 + // No key → prefer the Hub, same ladder as rest_run() (#426).
1058 + if ( '' === trim( $api_key ) ) {
1059 + $via_hub = $this->start_psi_via_hub( $url, $strategy );
1060 + if ( $via_hub instanceof \WP_Error ) {
1061 + \WP_CLI::error( $via_hub->get_error_message() );
1062 + return;
1063 + }
1064 + if ( null !== $via_hub ) {
1065 + \WP_CLI::success( 'PageSpeed audit started via xSpeed Hub. Poll with: wp xspeed score status' );
1066 + return;
1067 + }
1068 + }
1069 +
1070 + $run = Score::run_psi( $url, $strategy, $api_key );
1071 +
829 1072 if ( empty( $run['ok'] ) ) {
830 1073 \WP_CLI::error( (string) $run['error'] );
831 1074 return;
832 1075 }
@@ -844,15 +1087,24 @@
844 1087
845 1088 // status
846 1089 $pending = get_option( Score::PENDING_OPTION, array() );
847 1090 if ( $this->may_poll( $opts, $pending ) ) {
848 - $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
1091 + $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
1092 + ? $this->poll_hub_psi( $pending )
1093 + : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
849 1094 if ( is_wp_error( $polled ) ) {
850 1095 \WP_CLI::error( $polled->get_error_message() );
851 1096 return;
852 1097 }
853 1098 if ( ! empty( $polled['pending'] ) ) {
854 - \WP_CLI::log( sprintf( 'GTmetrix test %s is %s.', (string) $pending['test_id'], (string) ( $polled['state'] ?? 'running' ) ) );
1099 + \WP_CLI::log(
1100 + sprintf(
1101 + '%s test %s is %s.',
1102 + 'hub-psi' === ( $pending['provider'] ?? '' ) ? 'PageSpeed (Hub)' : 'GTmetrix',
1103 + (string) $pending['test_id'],
1104 + (string) ( $polled['state'] ?? 'running' )
1105 + )
1106 + );
855 1107 return;
856 1108 }
857 1109 }
858 1110