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
xspeed / includes / modules / Score / ScoreModule.php

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

1,149 lines 38.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Score — external performance scores (PageSpeed Insights / GTmetrix)
4 * on the dashboard (issue #47).
5 *
6 * Free tier. Users judge a caching plugin by its PSI or GTmetrix score
7 * whether or not the plugin shows one, so the number belongs next to the
8 * internal TTFB benchmark instead of one browser tab away.
9 *
10 * The module owns settings, REST and CLI; the measuring lives in
11 * \XSpeed\Score. Runs are stored in the shape Pro's Pagespeed engine
12 * already returns, so a Pro audit and a Free audit are the same kind of
13 * row and the history stays a single series — Pro augments this rather
14 * than starting a second one.
15 *
16 * Outbound HTTP is opt-in: nothing here calls out unless the module is
17 * enabled AND a person presses Test (or runs the command). No schedule,
18 * no background call. Disclosed in readme.txt "External services".
19 *
20 * @package XSpeed
21 */
22
23 declare(strict_types=1);
24
25 namespace XSpeed\Modules\Score;
26
27 defined( 'ABSPATH' ) || exit;
28
29 use XSpeed\Module;
30 use XSpeed\Modules\Mcp\Mcp_Hub;
31 use XSpeed\Modules\Mcp\Mcp_Pairing;
32 use XSpeed\Scan;
33 use XSpeed\Score;
34 use XSpeed\Settings_Manager;
35
36 final class ScoreModule extends Module {
37
38 public const SLUG = 'score';
39 public const TIER = self::TIER_FREE;
40 public const VERSION = '1.1.0';
41
42 /**
43 * No On/Off state (#425).
44 *
45 * The default reports the `enabled` setting, which put an "Off" pill on a
46 * panel whose Test button works regardless — the press is the consent and
47 * flips the setting itself. A run-on-demand panel has no meaningful
48 * on/off, exactly like Health; the setting stays as the internal gate for
49 * non-press callers (optimize runs, GTmetrix polling), it just is not a
50 * state this panel wears.
51 */
52 public function is_active(): ?bool {
53 return null;
54 }
55
56 public function ui_metadata(): array {
57 return array(
58 'label' => __( 'Speed Test', 'xspeed' ),
59 'icon' => 'Gauge',
60 // Provider-neutral: the panel runs whichever provider the site has
61 // configured (PageSpeed Insights by default, no API key needed).
62 // The Hub-run test has its own copy and is gated behind
63 // hub_speed_test_enabled(), so this line must not promise it.
64 'description' => __( 'Run a PageSpeed Insights or GTmetrix test and keep a history of the scores.', 'xspeed' ),
65 'custom_panel' => 'ScorePanel',
66 'group' => 'insights',
67 );
68 }
69
70 public function settings_schema(): array {
71 return array(
72 'enabled' => array(
73 'type' => 'bool',
74 'default' => false,
75 // Meaningful for the surfaces it still reaches (REST schema,
76 // CLI settings, a wp-config override): it names what the
77 // value permits, not a switch nobody sees.
78 'label' => __( 'Allow speed tests', 'xspeed' ),
79 // Off by default, but pressing Test IS the consent: the first
80 // run turns this on rather than refusing (#425). What it
81 // still guards is everything that is NOT a Test press — an
82 // optimize run measuring its own effect, for instance.
83 // Switch it off (REST/CLI) and nothing contacts a provider.
84 'description' => __( 'Turns on the first time you run a speed test. Switch it off and no feature, not even an optimize run, contacts a test provider.', 'xspeed' ),
85 // No dashboard control: the Test press manages it, and a
86 // visible switch that gates a button elsewhere was the
87 // confusion #425 removed. Hidden fields are skipped by the
88 // panel renderer and by settings search.
89 'hidden' => true,
90 ),
91 'provider' => array(
92 'type' => 'enum',
93 'default' => 'psi',
94 'options' => array( 'psi', 'gtmetrix' ),
95 'option_labels' => array(
96 'psi' => 'PageSpeed Insights',
97 'gtmetrix' => 'GTmetrix',
98 ),
99 'label' => __( 'Test provider', 'xspeed' ),
100 'description' => __( 'PageSpeed Insights works without an API key. GTmetrix needs one.', 'xspeed' ),
101 ),
102 'psi_api_key' => array(
103 'type' => 'secret',
104 'default' => '',
105 'label' => __( 'PageSpeed API key (optional)', 'xspeed' ),
106 'description' => __( 'Only needed if Google starts refusing tests without a key. You can get a free key at cloud.google.com.', 'xspeed' ),
107 // Rendered as a trailing "Check the documentation" link —
108 // descriptions themselves are plain text (#111).
109 'doc_url' => 'https://xspeedcache.com/docs/pagespeed-insights-integration/',
110 'advanced' => true,
111 'dependsOn' => array(
112 'field' => 'provider',
113 'value' => 'psi',
114 ),
115 ),
116 'gtmetrix_api_key' => array(
117 'type' => 'secret',
118 'default' => '',
119 'label' => __( 'GTmetrix API key', 'xspeed' ),
120 'description' => __( 'GTmetrix does not run tests without a key. Find it in your GTmetrix account settings.', 'xspeed' ),
121 'dependsOn' => array(
122 'field' => 'provider',
123 'value' => 'gtmetrix',
124 ),
125 ),
126 'test_url' => array(
127 'type' => 'url',
128 'default' => '',
129 'label' => __( 'URL to test', 'xspeed' ),
130 'description' => __( 'Leave empty to test your home page.', 'xspeed' ),
131 ),
132 'default_strategy' => array(
133 'type' => 'enum',
134 'default' => 'mobile',
135 'options' => array( 'mobile', 'desktop' ),
136 'label' => __( 'Device', 'xspeed' ),
137 'description' => __( 'Test as a phone or a desktop visitor. Google ranks sites on the mobile result.', 'xspeed' ),
138 'dependsOn' => array(
139 'field' => 'provider',
140 'value' => 'psi',
141 ),
142 ),
143 );
144 }
145
146 /**
147 * Encrypt the pre-1.1.0 plaintext API keys on upgrade — psi_api_key /
148 * gtmetrix_api_key became `secret`-typed fields (encrypted at rest).
149 * Idempotent. (#115)
150 */
151 public function migrations(): array {
152 return array(
153 '1.1.0' => static function ( array $opts ): array {
154 foreach ( array( 'psi_api_key', 'gtmetrix_api_key' ) as $key ) {
155 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
156 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
157 }
158 }
159 return $opts;
160 },
161 );
162 }
163
164 public function rest_routes(): array {
165 return array_merge(
166 parent::rest_routes(),
167 array(
168 array(
169 'path' => '/run',
170 'methods' => 'POST',
171 'callback' => array( $this, 'rest_run' ),
172 'feature' => self::SLUG,
173 ),
174 array(
175 'path' => '/status',
176 'methods' => 'GET',
177 'callback' => array( $this, 'rest_status' ),
178 ),
179 array(
180 'path' => '/history',
181 'methods' => 'GET',
182 'callback' => array( $this, 'rest_history' ),
183 ),
184 /*
185 * Hub-powered GTmetrix. Deliberately NOT gated on the
186 * `enabled` setting the way /run and /status are.
187 *
188 * That gate exists because /run makes an outbound call on the
189 * SITE's behalf with the SITE owner's key — it is the promise
190 * in readme.txt that nothing is sent to a third party until
191 * you say so. This path is different: the site talks only to
192 * the Hub it has already been deliberately connected to, and
193 * the Hub owns the GTmetrix account. Requiring the toggle as
194 * well would keep the five-step funnel this feature exists to
195 * remove.
196 */
197 array(
198 'path' => '/hub-test',
199 'methods' => 'POST',
200 'callback' => array( $this, 'rest_hub_test' ),
201 ),
202 array(
203 'path' => '/hub-status',
204 'methods' => 'GET',
205 'callback' => array( $this, 'rest_hub_status' ),
206 ),
207 /*
208 * xSpeed Scan. Like the Hub routes above, deliberately NOT
209 * gated on the `enabled` setting: that toggle guards sending
210 * the site's URL to Google or GTmetrix with the SITE owner's
211 * own API key. The scan engine is our own service, needs no
212 * key, and the user starts it by pressing Scan — the consent
213 * is the press, and requiring a settings toggle first would
214 * reinstate exactly the funnel this feature removes.
215 *
216 * What the UI MUST NOT skip is telling the user whether the
217 * resulting report is public; see Scan::private_supported().
218 */
219 array(
220 'path' => '/scan',
221 'methods' => 'POST',
222 'callback' => array( $this, 'rest_scan_start' ),
223 ),
224 array(
225 'path' => '/scan-status',
226 'methods' => 'GET',
227 'callback' => array( $this, 'rest_scan_status' ),
228 ),
229 )
230 );
231 }
232
233 /**
234 * Start an xSpeed Scan.
235 *
236 * Answers as soon as the engine accepts the run — a scan takes 20-60s,
237 * so holding the request open would trip every proxy between here and
238 * the browser. The caller polls /scan-status.
239 *
240 * @return \WP_REST_Response|\WP_Error
241 */
242 public function rest_scan_start( \WP_REST_Request $request ) {
243 // One at a time. Without this a double-click spends two runs against
244 // the engine's rate limit and leaves two pending markers racing.
245 $pending = Scan::pending();
246 if ( null !== $pending ) {
247 return rest_ensure_response(
248 array(
249 'status' => 'running',
250 'scan_id' => $pending['scan_id'],
251 'step' => '',
252 )
253 );
254 }
255
256 $started = Scan::start(
257 (string) ( $request->get_param( 'url' ) ?? '' ),
258 (bool) $request->get_param( 'fresh' ),
259 // `both` (default), `desktop` or `mobile` — which Lighthouse runs
260 // the engine spends and therefore which device is the headline.
261 (string) ( $request->get_param( 'strategy' ) ?? 'both' )
262 );
263 if ( is_wp_error( $started ) ) {
264 return $started;
265 }
266
267 return rest_ensure_response(
268 array(
269 'status' => 'running',
270 'scan_id' => $started['scan_id'],
271 'report_url' => $started['report_url'],
272 'cached' => $started['cached'],
273 )
274 );
275 }
276
277 /**
278 * Poll the in-flight scan, or report the last completed one.
279 *
280 * Always 200: "nothing has been scanned yet" is an empty state, not an
281 * error, and the dashboard renders it on first paint.
282 *
283 * @return \WP_REST_Response|\WP_Error
284 */
285 public function rest_scan_status() {
286 $pending = Scan::pending();
287
288 if ( null !== $pending ) {
289 $polled = Scan::poll( (string) $pending['scan_id'] );
290 if ( is_wp_error( $polled ) ) {
291 return $polled;
292 }
293 if ( isset( $polled['status'] ) && 'running' === $polled['status'] ) {
294 return rest_ensure_response(
295 array(
296 'status' => 'running',
297 'scan_id' => $polled['scan_id'],
298 'step' => $polled['step'],
299 'latest' => Scan::latest(),
300 'stored' => Scan::stored_state(),
301 'visibility' => Scan::visibility(),
302 'private_supported' => Scan::private_supported(),
303 )
304 );
305 }
306 return rest_ensure_response(
307 array(
308 'status' => 'complete',
309 'latest' => $polled,
310 'stored' => 'ok',
311 'visibility' => Scan::visibility(),
312 'private_supported' => Scan::private_supported(),
313 )
314 );
315 }
316
317 return rest_ensure_response(
318 array(
319 'status' => 'idle',
320 'latest' => Scan::latest(),
321 'stored' => Scan::stored_state(),
322 'visibility' => Scan::visibility(),
323 'private_supported' => Scan::private_supported(),
324 )
325 );
326 }
327
328 /**
329 * Start a Hub-run GTmetrix test.
330 *
331 * Returns the Hub's payload on success. On failure the WP_Error code is
332 * the Hub's own stable code (site_not_verified, gtmetrix_quota_exceeded,
333 * …) so the UI can respond specifically, and the HTTP status is carried
334 * through rather than flattened to 500.
335 *
336 * @return \WP_REST_Response|\WP_Error
337 */
338 public function rest_hub_test() {
339 if ( ! self::hub_speed_test_enabled() ) {
340 return new \WP_Error(
341 'xspeed_hub_speed_test_disabled',
342 __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
343 array( 'status' => 403 )
344 );
345 }
346
347 $result = Mcp_Hub::gtmetrix_test();
348 return $this->hub_result( $result );
349 }
350
351 /**
352 * Is the Hub-run speed test available on this site?
353 *
354 * Off by default: the feature is built and merged, but the hosted runner
355 * behind it is not being announced yet, and a button that offers a test we
356 * are not ready to serve is worse than no button. The panel already has a
357 * complete story without it — PageSpeed Insights runs with no API key, and
358 * a site with its own provider key is unaffected either way.
359 *
360 * `rest_hub_status()` reports `feature_disabled`, which is deliberately NOT
361 * in the UI's CONNECTABLE_REASONS allowlist, so both surfaces (the Overview
362 * card and the panel's primary action) fall back to the PageSpeed flow
363 * rather than offering a Connect prompt that leads nowhere.
364 *
365 * Flip with `add_filter( 'xspeed_hub_speed_test_enabled', '__return_true' );`
366 * — one line, no code change, for when the runner is announced.
367 */
368 public static function hub_speed_test_enabled(): bool {
369 /**
370 * Whether the Hub-run speed test is offered in the dashboard.
371 *
372 * @param bool $enabled Default false.
373 */
374 return (bool) apply_filters( 'xspeed_hub_speed_test_enabled', false );
375 }
376
377 /**
378 * Recent Hub-run tests + the remaining monthly allowance.
379 *
380 * @return \WP_REST_Response|\WP_Error
381 */
382 public function rest_hub_status() {
383 if ( ! self::hub_speed_test_enabled() ) {
384 return rest_ensure_response(
385 array(
386 'available' => false,
387 'reason' => 'feature_disabled',
388 'message' => __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
389 'local' => false,
390 )
391 );
392 }
393
394 $result = Mcp_Hub::gtmetrix_runs();
395 if ( is_wp_error( $result ) ) {
396 // A status read must never look like a hard failure — the panel
397 // still has a history to draw. Report "unavailable" and let the UI
398 // hide the Hub affordance rather than showing an error banner.
399 return rest_ensure_response(
400 array(
401 'available' => false,
402 'reason' => $result->get_error_code(),
403 'message' => $result->get_error_message(),
404 'local' => Mcp_Hub::is_local_site(),
405 )
406 );
407 }
408
409 $result['available'] = true;
410 $result['local'] = Mcp_Hub::is_local_site();
411 return rest_ensure_response( $result );
412 }
413
414 /**
415 * Turn an Mcp_Hub result into a REST response, preserving the status code.
416 *
417 * @param array<string,mixed>|\WP_Error $result Hub call result.
418 * @return \WP_REST_Response|\WP_Error
419 */
420 private function hub_result( $result ) {
421 if ( ! is_wp_error( $result ) ) {
422 return rest_ensure_response( $result );
423 }
424
425 $data = $result->get_error_data();
426 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502;
427 // Anything below 400 would be nonsense on an error path.
428 if ( $status < 400 ) {
429 $status = 502;
430 }
431 $data = is_array( $data ) ? $data : array();
432 $data['status'] = $status;
433 $data['local'] = Mcp_Hub::is_local_site();
434
435 return new \WP_Error( $result->get_error_code(), $result->get_error_message(), $data );
436 }
437
438 /**
439 * Start (or, for PSI, complete) an audit.
440 *
441 * POST, never GET: this spends someone else's rate limit and takes up
442 * to a minute. A GET would be prefetched by a browser.
443 */
444 public function rest_run( \WP_REST_Request $request ) {
445 $opts = $this->consent_by_running( Settings_Manager::get( self::SLUG ) );
446
447 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
448 if ( '' === $url ) {
449 return new \WP_Error(
450 'xspeed_score_no_url',
451 __( 'No URL to test.', 'xspeed' ),
452 array( 'status' => 400 )
453 );
454 }
455
456 $provider = (string) ( $request->get_param( 'provider' ) ?: $opts['provider'] );
457
458 if ( 'gtmetrix' === $provider ) {
459 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
460 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
461 }
462
463 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
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 ) );
479 }
480
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 /**
656 * Poll an in-flight GTmetrix test.
657 *
658 * GET because it is a read of state we already started — the browser
659 * calls it every few seconds while a test is queued.
660 */
661 public function rest_status() {
662 $opts = Settings_Manager::get( self::SLUG );
663 $pending = get_option( Score::PENDING_OPTION, array() );
664
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.)
670 if ( ! $this->may_poll( $opts, $pending ) ) {
671 return rest_ensure_response(
672 array(
673 'pending' => false,
674 'state' => 'idle',
675 'latest' => Score::latest(),
676 )
677 );
678 }
679
680 $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
681 ? $this->poll_hub_psi( $pending )
682 : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
683 if ( is_wp_error( $polled ) ) {
684 return $polled;
685 }
686
687 return rest_ensure_response(
688 array_merge(
689 $polled,
690 array( 'latest' => Score::latest() )
691 )
692 );
693 }
694
695 public function rest_history() {
696 return rest_ensure_response(
697 array(
698 'runs' => Score::history(),
699 'latest' => Score::latest(),
700 'thresholds' => Score::thresholds(),
701 )
702 );
703 }
704
705 /**
706 * May we contact anyone to poll the in-flight test?
707 *
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.
714 *
715 * @param array<string,mixed> $opts Module settings.
716 * @param mixed $pending The stored pending marker.
717 */
718 private function may_poll( array $opts, $pending ): bool {
719 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
720 return false;
721 }
722 // A test that hasn't resolved within the window is not going to;
723 // drop the marker rather than poll it forever.
724 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
725 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
726 delete_option( Score::PENDING_OPTION );
727 return false;
728 }
729 if ( 'hub-psi' === ( $pending['provider'] ?? '' ) ) {
730 return '' !== Mcp_Pairing::site_token();
731 }
732 return ! empty( $opts['enabled'] ) && '' !== trim( (string) $opts['gtmetrix_api_key'] );
733 }
734
735 /**
736 * Fall back to the home page when no URL is configured — testing "my
737 * site" is what almost everyone means.
738 *
739 * @param array<string,mixed> $opts Module settings.
740 */
741 private function resolve_url( string $requested, array $opts ): string {
742 foreach ( array( $requested, (string) ( $opts['test_url'] ?? '' ) ) as $candidate ) {
743 $candidate = trim( $candidate );
744 if ( '' !== $candidate ) {
745 return $candidate;
746 }
747 }
748 return function_exists( 'home_url' ) ? (string) home_url( '/' ) : '';
749 }
750
751 public function cli_commands(): array {
752 return array(
753 array(
754 'name' => 'xspeed score',
755 'callback' => array( $this, 'cli_handler' ),
756 '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.',
757 '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.',
758 'synopsis' => array(
759 array(
760 'type' => 'positional',
761 'name' => 'action',
762 'options' => array( 'status', 'run', 'history', 'hub-test' ),
763 'optional' => true,
764 ),
765 array(
766 'type' => 'assoc',
767 // NOT `--url`: that is a WP-CLI *global* parameter, so
768 // the value never reaches this handler and the flag is
769 // silently ignored.
770 'name' => 'target',
771 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
772 'optional' => true,
773 ),
774 array(
775 'type' => 'assoc',
776 'name' => 'strategy',
777 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
778 'optional' => true,
779 ),
780 array(
781 'type' => 'assoc',
782 'name' => 'provider',
783 'description' => 'psi (default) or gtmetrix.',
784 'optional' => true,
785 ),
786 ),
787 ),
788 /*
789 * A SEPARATE command, not another action on `score`, because the
790 * two produce different numbers. A scan score is the xSpeed
791 * rubric (four weighted dimensions, ~20 checks); a score run is a
792 * raw provider score. Folding them together would invite exactly
793 * the comparison the two scales cannot support.
794 */
795 array(
796 'name' => 'xspeed scan',
797 'callback' => array( $this, 'cli_scan_handler' ),
798 'shortdesc' => 'xSpeed Scan: `run` a full graded site report, `status` to poll one or read the last, `fixes` for what to do next.',
799 '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.',
800 'synopsis' => array(
801 array(
802 'type' => 'positional',
803 'name' => 'action',
804 'options' => array( 'run', 'status', 'fixes' ),
805 'optional' => true,
806 ),
807 array(
808 'type' => 'assoc',
809 // NOT `--url`, which WP-CLI reserves as a global.
810 'name' => 'target',
811 'description' => 'URL to scan. Defaults to the home page.',
812 'optional' => true,
813 ),
814 array(
815 'type' => 'flag',
816 'name' => 'fresh',
817 'description' => 'Force a new scan instead of reusing a recent cached report.',
818 'optional' => true,
819 ),
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(
828 'type' => 'flag',
829 'name' => 'wait',
830 'description' => 'Poll until the scan finishes instead of returning immediately.',
831 'optional' => true,
832 ),
833 ),
834 ),
835 );
836 }
837
838 /**
839 * `wp xspeed scan [run|status|fixes]`
840 *
841 * @param array<int,string> $args Positional.
842 * @param array<string,string> $assoc_args Flags.
843 */
844 public function cli_scan_handler( array $args, array $assoc_args ): void {
845 $action = $args[0] ?? 'status';
846
847 if ( 'fixes' === $action ) {
848 $latest = Scan::latest();
849 if ( null === $latest || empty( $latest['fixes'] ) ) {
850 \WP_CLI::log( 'No scan result yet. Run `wp xspeed scan run --wait` first.' );
851 return;
852 }
853 $rows = array();
854 foreach ( $latest['fixes'] as $f ) {
855 $rows[] = array(
856 'check' => $f['id'] . ' ' . $f['name'],
857 'status' => $f['status'],
858 'recoverable' => $f['recoverable'],
859 'evidence' => $f['evidence'],
860 );
861 }
862 \WP_CLI\Utils\format_items( 'table', $rows, array( 'check', 'status', 'recoverable', 'evidence' ) );
863 return;
864 }
865
866 if ( 'run' === $action ) {
867 // Said before the call, not after: on an unconnected site this
868 // publishes a report about the user's site to a public list.
869 // Name the remedy too -- connecting the Hub is what makes a
870 // report unlisted, and a warning without it leaves no move.
871 if ( 'private' !== Scan::visibility() || ! Scan::private_supported() ) {
872 \WP_CLI::warning(
873 'This report will be publicly visible at xspeedcache.com, listed under your domain. '
874 . 'Connect xSpeed Hub from the dashboard to keep your reports unlisted.'
875 );
876 }
877
878 $started = Scan::start(
879 (string) ( $assoc_args['target'] ?? '' ),
880 ! empty( $assoc_args['fresh'] ),
881 (string) ( $assoc_args['strategy'] ?? 'both' )
882 );
883 if ( is_wp_error( $started ) ) {
884 \WP_CLI::error( $started->get_error_message() );
885 return;
886 }
887 \WP_CLI::log( 'Scan started: ' . $started['scan_id'] );
888 \WP_CLI::log( 'Report: ' . $started['report_url'] );
889
890 if ( empty( $assoc_args['wait'] ) ) {
891 \WP_CLI::log( 'Poll with `wp xspeed scan status`.' );
892 return;
893 }
894
895 // A scan is 20-60s; cap the wait so a stuck engine cannot hang
896 // a CLI session indefinitely.
897 $deadline = time() + 180;
898 while ( time() < $deadline ) {
899 sleep( 5 );
900 $polled = Scan::poll( (string) $started['scan_id'] );
901 if ( is_wp_error( $polled ) ) {
902 \WP_CLI::error( $polled->get_error_message() );
903 return;
904 }
905 if ( ! isset( $polled['status'] ) || 'running' !== $polled['status'] ) {
906 self::cli_print_scan( $polled );
907 return;
908 }
909 \WP_CLI::log( ' ' . ( $polled['step'] ?: 'working' ) . '...' );
910 }
911 \WP_CLI::warning( 'Still running. Poll with `wp xspeed scan status`.' );
912 return;
913 }
914
915 // status
916 $pending = Scan::pending();
917 if ( null !== $pending ) {
918 $polled = Scan::poll( (string) $pending['scan_id'] );
919 if ( is_wp_error( $polled ) ) {
920 \WP_CLI::error( $polled->get_error_message() );
921 return;
922 }
923 if ( isset( $polled['status'] ) && 'running' === $polled['status'] ) {
924 \WP_CLI::log( 'Running: ' . ( $polled['step'] ?: 'working' ) );
925 return;
926 }
927 self::cli_print_scan( $polled );
928 return;
929 }
930
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 }
936 if ( null === $latest ) {
937 \WP_CLI::log( 'No scan yet. Run `wp xspeed scan run --wait`.' );
938 return;
939 }
940 self::cli_print_scan( $latest );
941 }
942
943 /** Shared rendering for a completed scan. */
944 private static function cli_print_scan( array $r ): void {
945 \WP_CLI::log(
946 sprintf(
947 'Score %s/100 grade %s (%s)%s',
948 null === $r['score'] ? '-' : $r['score'],
949 $r['grade'] ?: '-',
950 $r['level_name'] ?: '-',
951 ! empty( $r['partial'] ) ? ' [partial scan]' : ''
952 )
953 );
954 foreach ( (array) ( $r['dimensions'] ?? array() ) as $key => $d ) {
955 \WP_CLI::log( sprintf( ' %-9s %3s/100 (%s of %s pts)', $key, $d['score'] ?? '-', $d['earned'] ?? '-', $d['weight'] ?? '-' ) );
956 }
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;
961 $lhd = $r['measured']['lighthouse_desktop'] ?? null;
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'] ?? '' );
974 \WP_CLI::log(
975 sprintf(
976 'Lighthouse: %s - a different scale, one check inside the score above.%s',
977 implode( ', ', $parts ),
978 '' !== $graded ? ' Graded on ' . $graded . '.' : ''
979 )
980 );
981 }
982 \WP_CLI::log( 'Report: ' . ( $r['report_url'] ?? '' ) );
983 }
984
985 public function cli_handler( array $args, array $assoc ): void {
986 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
987 $opts = Settings_Manager::get( self::SLUG );
988
989 /*
990 * Hub-powered GTmetrix. Handled before the `enabled` gate below: this
991 * path does not use the site's own key or settings at all — it asks
992 * the Hub the site is already connected to, and the Hub owns the
993 * GTmetrix account.
994 */
995 if ( 'hub-test' === $action ) {
996 if ( ! self::hub_speed_test_enabled() ) {
997 \WP_CLI::error( 'Hub-run speed tests are not enabled on this site.' );
998 return;
999 }
1000 $result = Mcp_Hub::gtmetrix_test();
1001 if ( is_wp_error( $result ) ) {
1002 \WP_CLI::error( $result->get_error_message() );
1003 return;
1004 }
1005 $quota = isset( $result['quota'] ) && is_array( $result['quota'] ) ? $result['quota'] : array();
1006 \WP_CLI::success(
1007 sprintf(
1008 'Test started. %s left this month.',
1009 isset( $quota['remaining'] ) ? (string) (int) $quota['remaining'] : '?'
1010 )
1011 );
1012 \WP_CLI::log( 'Results appear in the dashboard when the test finishes (about a minute).' );
1013 return;
1014 }
1015
1016 if ( 'history' === $action ) {
1017 $runs = Score::history();
1018 if ( empty( $runs ) ) {
1019 \WP_CLI::log( 'No runs recorded yet.' );
1020 return;
1021 }
1022 foreach ( $runs as $run ) {
1023 \WP_CLI::log(
1024 sprintf(
1025 '%s %-9s %-8s %s',
1026 gmdate( 'Y-m-d H:i', (int) $run['ts'] ),
1027 (string) ( $run['provider'] ?? '' ),
1028 empty( $run['ok'] ) ? 'FAILED' : ( null === ( $run['score'] ?? null ) ? 'no score' : $run['score'] . '/100' ),
1029 empty( $run['ok'] ) ? (string) ( $run['error'] ?? '' ) : (string) ( $run['url'] ?? '' )
1030 )
1031 );
1032 }
1033 return;
1034 }
1035
1036 if ( 'run' === $action ) {
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 );
1040
1041 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
1042 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
1043
1044 if ( 'gtmetrix' === $provider ) {
1045 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
1046 if ( is_wp_error( $started ) ) {
1047 \WP_CLI::error( $started->get_error_message() );
1048 return;
1049 }
1050 \WP_CLI::success( sprintf( 'GTmetrix test queued (id %s). Poll with: wp xspeed score status', (string) ( $started['test_id'] ?? '?' ) ) );
1051 return;
1052 }
1053
1054 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
1055 $api_key = (string) $opts['psi_api_key'];
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
1072 if ( empty( $run['ok'] ) ) {
1073 \WP_CLI::error( (string) $run['error'] );
1074 return;
1075 }
1076 \WP_CLI::success(
1077 sprintf(
1078 '%s (%s): %s',
1079 $url,
1080 $strategy,
1081 null === $run['score'] ? 'no score returned' : $run['score'] . '/100'
1082 )
1083 );
1084 $this->print_metrics( is_array( $run['metrics'] ) ? $run['metrics'] : array() );
1085 return;
1086 }
1087
1088 // status
1089 $pending = get_option( Score::PENDING_OPTION, array() );
1090 if ( $this->may_poll( $opts, $pending ) ) {
1091 $polled = 'hub-psi' === ( $pending['provider'] ?? '' )
1092 ? $this->poll_hub_psi( $pending )
1093 : Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
1094 if ( is_wp_error( $polled ) ) {
1095 \WP_CLI::error( $polled->get_error_message() );
1096 return;
1097 }
1098 if ( ! empty( $polled['pending'] ) ) {
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 );
1107 return;
1108 }
1109 }
1110
1111 \WP_CLI::log( 'enabled ' . ( empty( $opts['enabled'] ) ? 'no' : 'yes' ) );
1112 \WP_CLI::log( 'provider ' . (string) $opts['provider'] );
1113
1114 $latest = Score::latest();
1115 if ( null === $latest ) {
1116 \WP_CLI::log( 'No successful run yet. Run one with: wp xspeed score run' );
1117 return;
1118 }
1119 \WP_CLI::log(
1120 sprintf(
1121 'latest %s — %s (%s)',
1122 null === $latest['score'] ? 'no score' : $latest['score'] . '/100',
1123 gmdate( 'Y-m-d H:i', (int) $latest['ts'] ),
1124 (string) $latest['provider']
1125 )
1126 );
1127 $this->print_metrics( is_array( $latest['metrics'] ) ? $latest['metrics'] : array() );
1128 }
1129
1130 /**
1131 * @param array<string,mixed> $metrics Metric name → value.
1132 */
1133 private function print_metrics( array $metrics ): void {
1134 foreach ( $metrics as $name => $value ) {
1135 if ( null === $value ) {
1136 continue;
1137 }
1138 \WP_CLI::log(
1139 sprintf(
1140 ' %-5s %-10s %s',
1141 strtoupper( (string) $name ),
1142 'cls' === $name ? (string) round( (float) $value, 3 ) : (int) $value . 'ms',
1143 Score::rate( (string) $name, (float) $value )
1144 )
1145 );
1146 }
1147 }
1148 }
1149