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