PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Score / ScoreModule.php

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

897 lines 29.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\Scan;
32 use XSpeed\Score;
33 use XSpeed\Settings_Manager;
34
35 final class ScoreModule extends Module {
36
37 public const SLUG = 'score';
38 public const TIER = self::TIER_FREE;
39 public const VERSION = '1.1.0';
40
41 public function ui_metadata(): array {
42 return array(
43 'label' => 'Speed Test',
44 'icon' => 'Gauge',
45 // Provider-neutral: the panel runs whichever provider the site has
46 // configured (PageSpeed Insights by default, no API key needed).
47 // The Hub-run test has its own copy and is gated behind
48 // 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.',
50 'custom_panel' => 'ScorePanel',
51 );
52 }
53
54 public function settings_schema(): array {
55 return array(
56 'enabled' => array(
57 'type' => 'bool',
58 '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.',
63 ),
64 'provider' => array(
65 'type' => 'enum',
66 'default' => 'psi',
67 'options' => array( 'psi', 'gtmetrix' ),
68 'option_labels' => array(
69 'psi' => 'PageSpeed Insights',
70 'gtmetrix' => 'GTmetrix',
71 ),
72 'label' => 'Provider',
73 'description' => 'PageSpeed Insights works without an API key. GTmetrix requires one.',
74 'dependsOn' => array( 'field' => 'enabled' ),
75 ),
76 'psi_api_key' => array(
77 'type' => 'secret',
78 'default' => '',
79 'label' => 'PageSpeed API key (optional)',
80 'description' => 'Only needed if you hit Google\'s anonymous rate limit. Free from cloud.google.com.',
81 // Rendered as a trailing "Check the documentation" link —
82 // descriptions themselves are plain text (#111).
83 'doc_url' => 'https://xspeedcache.com/docs/pagespeed-insights-integration/',
84 'dependsOn' => array(
85 'field' => 'provider',
86 'value' => 'psi',
87 ),
88 ),
89 'gtmetrix_api_key' => array(
90 'type' => 'secret',
91 'default' => '',
92 'label' => 'GTmetrix API key',
93 'description' => 'Required — GTmetrix has no anonymous mode. Found in your GTmetrix account settings.',
94 'dependsOn' => array(
95 'field' => 'provider',
96 'value' => 'gtmetrix',
97 ),
98 ),
99 'test_url' => array(
100 'type' => 'url',
101 'default' => '',
102 'label' => 'URL to test',
103 'description' => 'Leave empty to test your home page.',
104 'dependsOn' => array( 'field' => 'enabled' ),
105 ),
106 'default_strategy' => array(
107 'type' => 'enum',
108 'default' => 'mobile',
109 'options' => array( 'mobile', 'desktop' ),
110 'label' => 'Strategy',
111 'description' => 'PageSpeed Insights only. Mobile is what Google ranks on.',
112 'dependsOn' => array(
113 'field' => 'provider',
114 'value' => 'psi',
115 ),
116 ),
117 );
118 }
119
120 /**
121 * Encrypt the pre-1.1.0 plaintext API keys on upgrade — psi_api_key /
122 * gtmetrix_api_key became `secret`-typed fields (encrypted at rest).
123 * Idempotent. (#115)
124 */
125 public function migrations(): array {
126 return array(
127 '1.1.0' => static function ( array $opts ): array {
128 foreach ( array( 'psi_api_key', 'gtmetrix_api_key' ) as $key ) {
129 if ( isset( $opts[ $key ] ) && is_string( $opts[ $key ] ) && '' !== $opts[ $key ] ) {
130 $opts[ $key ] = \XSpeed\Settings_Manager::encrypt_for_storage( $opts[ $key ] );
131 }
132 }
133 return $opts;
134 },
135 );
136 }
137
138 public function rest_routes(): array {
139 return array_merge(
140 parent::rest_routes(),
141 array(
142 array(
143 'path' => '/run',
144 'methods' => 'POST',
145 'callback' => array( $this, 'rest_run' ),
146 'feature' => self::SLUG,
147 ),
148 array(
149 'path' => '/status',
150 'methods' => 'GET',
151 'callback' => array( $this, 'rest_status' ),
152 ),
153 array(
154 'path' => '/history',
155 'methods' => 'GET',
156 'callback' => array( $this, 'rest_history' ),
157 ),
158 /*
159 * Hub-powered GTmetrix. Deliberately NOT gated on the
160 * `enabled` setting the way /run and /status are.
161 *
162 * That gate exists because /run makes an outbound call on the
163 * SITE's behalf with the SITE owner's key — it is the promise
164 * in readme.txt that nothing is sent to a third party until
165 * you say so. This path is different: the site talks only to
166 * the Hub it has already been deliberately connected to, and
167 * the Hub owns the GTmetrix account. Requiring the toggle as
168 * well would keep the five-step funnel this feature exists to
169 * remove.
170 */
171 array(
172 'path' => '/hub-test',
173 'methods' => 'POST',
174 'callback' => array( $this, 'rest_hub_test' ),
175 ),
176 array(
177 'path' => '/hub-status',
178 'methods' => 'GET',
179 'callback' => array( $this, 'rest_hub_status' ),
180 ),
181 /*
182 * xSpeed Scan. Like the Hub routes above, deliberately NOT
183 * gated on the `enabled` setting: that toggle guards sending
184 * the site's URL to Google or GTmetrix with the SITE owner's
185 * own API key. The scan engine is our own service, needs no
186 * key, and the user starts it by pressing Scan — the consent
187 * is the press, and requiring a settings toggle first would
188 * reinstate exactly the funnel this feature removes.
189 *
190 * What the UI MUST NOT skip is telling the user whether the
191 * resulting report is public; see Scan::private_supported().
192 */
193 array(
194 'path' => '/scan',
195 'methods' => 'POST',
196 'callback' => array( $this, 'rest_scan_start' ),
197 ),
198 array(
199 'path' => '/scan-status',
200 'methods' => 'GET',
201 'callback' => array( $this, 'rest_scan_status' ),
202 ),
203 )
204 );
205 }
206
207 /**
208 * Start an xSpeed Scan.
209 *
210 * Answers as soon as the engine accepts the run — a scan takes 20-60s,
211 * so holding the request open would trip every proxy between here and
212 * the browser. The caller polls /scan-status.
213 *
214 * @return \WP_REST_Response|\WP_Error
215 */
216 public function rest_scan_start( \WP_REST_Request $request ) {
217 // One at a time. Without this a double-click spends two runs against
218 // the engine's rate limit and leaves two pending markers racing.
219 $pending = Scan::pending();
220 if ( null !== $pending ) {
221 return rest_ensure_response(
222 array(
223 'status' => 'running',
224 'scan_id' => $pending['scan_id'],
225 'step' => '',
226 )
227 );
228 }
229
230 $started = Scan::start(
231 (string) ( $request->get_param( 'url' ) ?? '' ),
232 (bool) $request->get_param( 'fresh' )
233 );
234 if ( is_wp_error( $started ) ) {
235 return $started;
236 }
237
238 return rest_ensure_response(
239 array(
240 'status' => 'running',
241 'scan_id' => $started['scan_id'],
242 'report_url' => $started['report_url'],
243 'cached' => $started['cached'],
244 )
245 );
246 }
247
248 /**
249 * Poll the in-flight scan, or report the last completed one.
250 *
251 * Always 200: "nothing has been scanned yet" is an empty state, not an
252 * error, and the dashboard renders it on first paint.
253 *
254 * @return \WP_REST_Response|\WP_Error
255 */
256 public function rest_scan_status() {
257 $pending = Scan::pending();
258
259 if ( null !== $pending ) {
260 $polled = Scan::poll( (string) $pending['scan_id'] );
261 if ( is_wp_error( $polled ) ) {
262 return $polled;
263 }
264 if ( isset( $polled['status'] ) && 'running' === $polled['status'] ) {
265 return rest_ensure_response(
266 array(
267 'status' => 'running',
268 'scan_id' => $polled['scan_id'],
269 'step' => $polled['step'],
270 'latest' => Scan::latest(),
271 'visibility' => Scan::visibility(),
272 'private_supported' => Scan::private_supported(),
273 )
274 );
275 }
276 return rest_ensure_response(
277 array(
278 'status' => 'complete',
279 'latest' => $polled,
280 'visibility' => Scan::visibility(),
281 'private_supported' => Scan::private_supported(),
282 )
283 );
284 }
285
286 return rest_ensure_response(
287 array(
288 'status' => 'idle',
289 'latest' => Scan::latest(),
290 'visibility' => Scan::visibility(),
291 'private_supported' => Scan::private_supported(),
292 )
293 );
294 }
295
296 /**
297 * Start a Hub-run GTmetrix test.
298 *
299 * Returns the Hub's payload on success. On failure the WP_Error code is
300 * the Hub's own stable code (site_not_verified, gtmetrix_quota_exceeded,
301 * …) so the UI can respond specifically, and the HTTP status is carried
302 * through rather than flattened to 500.
303 *
304 * @return \WP_REST_Response|\WP_Error
305 */
306 public function rest_hub_test() {
307 if ( ! self::hub_speed_test_enabled() ) {
308 return new \WP_Error(
309 'xspeed_hub_speed_test_disabled',
310 __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
311 array( 'status' => 403 )
312 );
313 }
314
315 $result = Mcp_Hub::gtmetrix_test();
316 return $this->hub_result( $result );
317 }
318
319 /**
320 * Is the Hub-run speed test available on this site?
321 *
322 * Off by default: the feature is built and merged, but the hosted runner
323 * behind it is not being announced yet, and a button that offers a test we
324 * are not ready to serve is worse than no button. The panel already has a
325 * complete story without it — PageSpeed Insights runs with no API key, and
326 * a site with its own provider key is unaffected either way.
327 *
328 * `rest_hub_status()` reports `feature_disabled`, which is deliberately NOT
329 * in the UI's CONNECTABLE_REASONS allowlist, so both surfaces (the Overview
330 * card and the panel's primary action) fall back to the PageSpeed flow
331 * rather than offering a Connect prompt that leads nowhere.
332 *
333 * Flip with `add_filter( 'xspeed_hub_speed_test_enabled', '__return_true' );`
334 * — one line, no code change, for when the runner is announced.
335 */
336 public static function hub_speed_test_enabled(): bool {
337 /**
338 * Whether the Hub-run speed test is offered in the dashboard.
339 *
340 * @param bool $enabled Default false.
341 */
342 return (bool) apply_filters( 'xspeed_hub_speed_test_enabled', false );
343 }
344
345 /**
346 * Recent Hub-run tests + the remaining monthly allowance.
347 *
348 * @return \WP_REST_Response|\WP_Error
349 */
350 public function rest_hub_status() {
351 if ( ! self::hub_speed_test_enabled() ) {
352 return rest_ensure_response(
353 array(
354 'available' => false,
355 'reason' => 'feature_disabled',
356 'message' => __( 'Hub-run speed tests are not enabled on this site.', 'xspeed' ),
357 'local' => false,
358 )
359 );
360 }
361
362 $result = Mcp_Hub::gtmetrix_runs();
363 if ( is_wp_error( $result ) ) {
364 // A status read must never look like a hard failure — the panel
365 // still has a history to draw. Report "unavailable" and let the UI
366 // hide the Hub affordance rather than showing an error banner.
367 return rest_ensure_response(
368 array(
369 'available' => false,
370 'reason' => $result->get_error_code(),
371 'message' => $result->get_error_message(),
372 'local' => Mcp_Hub::is_local_site(),
373 )
374 );
375 }
376
377 $result['available'] = true;
378 $result['local'] = Mcp_Hub::is_local_site();
379 return rest_ensure_response( $result );
380 }
381
382 /**
383 * Turn an Mcp_Hub result into a REST response, preserving the status code.
384 *
385 * @param array<string,mixed>|\WP_Error $result Hub call result.
386 * @return \WP_REST_Response|\WP_Error
387 */
388 private function hub_result( $result ) {
389 if ( ! is_wp_error( $result ) ) {
390 return rest_ensure_response( $result );
391 }
392
393 $data = $result->get_error_data();
394 $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 502;
395 // Anything below 400 would be nonsense on an error path.
396 if ( $status < 400 ) {
397 $status = 502;
398 }
399 $data = is_array( $data ) ? $data : array();
400 $data['status'] = $status;
401 $data['local'] = Mcp_Hub::is_local_site();
402
403 return new \WP_Error( $result->get_error_code(), $result->get_error_message(), $data );
404 }
405
406 /**
407 * Start (or, for PSI, complete) an audit.
408 *
409 * POST, never GET: this spends someone else's rate limit and takes up
410 * to a minute. A GET would be prefetched by a browser.
411 */
412 public function rest_run( \WP_REST_Request $request ) {
413 $opts = Settings_Manager::get( self::SLUG );
414
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 $url = $this->resolve_url( (string) $request->get_param( 'url' ), $opts );
424 if ( '' === $url ) {
425 return new \WP_Error(
426 'xspeed_score_no_url',
427 __( 'No URL to test.', 'xspeed' ),
428 array( 'status' => 400 )
429 );
430 }
431
432 $provider = (string) ( $request->get_param( 'provider' ) ?: $opts['provider'] );
433
434 if ( 'gtmetrix' === $provider ) {
435 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
436 return is_wp_error( $started ) ? $started : rest_ensure_response( $started );
437 }
438
439 $strategy = (string) ( $request->get_param( 'strategy' ) ?: $opts['default_strategy'] );
440 return rest_ensure_response( Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] ) );
441 }
442
443 /**
444 * Poll an in-flight GTmetrix test.
445 *
446 * GET because it is a read of state we already started — the browser
447 * calls it every few seconds while a test is queued.
448 */
449 public function rest_status() {
450 $opts = Settings_Manager::get( self::SLUG );
451 $pending = get_option( Score::PENDING_OPTION, array() );
452
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.
457 if ( ! $this->may_poll( $opts, $pending ) ) {
458 return rest_ensure_response(
459 array(
460 'pending' => false,
461 'state' => 'idle',
462 'latest' => Score::latest(),
463 )
464 );
465 }
466
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'] );
478 if ( is_wp_error( $polled ) ) {
479 return $polled;
480 }
481
482 return rest_ensure_response(
483 array_merge(
484 $polled,
485 array( 'latest' => Score::latest() )
486 )
487 );
488 }
489
490 public function rest_history() {
491 return rest_ensure_response(
492 array(
493 'runs' => Score::history(),
494 'latest' => Score::latest(),
495 'thresholds' => Score::thresholds(),
496 )
497 );
498 }
499
500 /**
501 * May we contact GTmetrix to poll the in-flight test?
502 *
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.
507 *
508 * @param array<string,mixed> $opts Module settings.
509 * @param mixed $pending The stored pending marker.
510 */
511 private function may_poll( array $opts, $pending ): bool {
512 if ( empty( $opts['enabled'] ) || '' === trim( (string) $opts['gtmetrix_api_key'] ) ) {
513 return false;
514 }
515 if ( ! is_array( $pending ) || empty( $pending['test_id'] ) ) {
516 return false;
517 }
518 // A GTmetrix test that hasn't resolved within the window is not going
519 // to; drop the marker rather than poll it forever.
520 $started = isset( $pending['started'] ) ? (int) $pending['started'] : 0;
521 if ( $started > 0 && ( time() - $started ) > Score::PENDING_MAX_AGE ) {
522 delete_option( Score::PENDING_OPTION );
523 return false;
524 }
525 return true;
526 }
527
528 /**
529 * Fall back to the home page when no URL is configured — testing "my
530 * site" is what almost everyone means.
531 *
532 * @param array<string,mixed> $opts Module settings.
533 */
534 private function resolve_url( string $requested, array $opts ): string {
535 foreach ( array( $requested, (string) ( $opts['test_url'] ?? '' ) ) as $candidate ) {
536 $candidate = trim( $candidate );
537 if ( '' !== $candidate ) {
538 return $candidate;
539 }
540 }
541 return function_exists( 'home_url' ) ? (string) home_url( '/' ) : '';
542 }
543
544 public function cli_commands(): array {
545 return array(
546 array(
547 'name' => 'xspeed score',
548 'callback' => array( $this, 'cli_handler' ),
549 '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.',
550 '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.',
551 'synopsis' => array(
552 array(
553 'type' => 'positional',
554 'name' => 'action',
555 'options' => array( 'status', 'run', 'history', 'hub-test' ),
556 'optional' => true,
557 ),
558 array(
559 'type' => 'assoc',
560 // NOT `--url`: that is a WP-CLI *global* parameter, so
561 // the value never reaches this handler and the flag is
562 // silently ignored.
563 'name' => 'target',
564 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
565 'optional' => true,
566 ),
567 array(
568 'type' => 'assoc',
569 'name' => 'strategy',
570 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
571 'optional' => true,
572 ),
573 array(
574 'type' => 'assoc',
575 'name' => 'provider',
576 'description' => 'psi (default) or gtmetrix.',
577 'optional' => true,
578 ),
579 ),
580 ),
581 /*
582 * A SEPARATE command, not another action on `score`, because the
583 * two produce different numbers. A scan score is the xSpeed
584 * rubric (four weighted dimensions, ~20 checks); a score run is a
585 * raw provider score. Folding them together would invite exactly
586 * the comparison the two scales cannot support.
587 */
588 array(
589 'name' => 'xspeed scan',
590 'callback' => array( $this, 'cli_scan_handler' ),
591 'shortdesc' => 'xSpeed Scan: `run` a full graded site report, `status` to poll one or read the last, `fixes` for what to do next.',
592 '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.',
593 'synopsis' => array(
594 array(
595 'type' => 'positional',
596 'name' => 'action',
597 'options' => array( 'run', 'status', 'fixes' ),
598 'optional' => true,
599 ),
600 array(
601 'type' => 'assoc',
602 // NOT `--url`, which WP-CLI reserves as a global.
603 'name' => 'target',
604 'description' => 'URL to scan. Defaults to the home page.',
605 'optional' => true,
606 ),
607 array(
608 'type' => 'flag',
609 'name' => 'fresh',
610 'description' => 'Force a new scan instead of reusing a recent cached report.',
611 'optional' => true,
612 ),
613 array(
614 'type' => 'flag',
615 'name' => 'wait',
616 'description' => 'Poll until the scan finishes instead of returning immediately.',
617 'optional' => true,
618 ),
619 ),
620 ),
621 );
622 }
623
624 /**
625 * `wp xspeed scan [run|status|fixes]`
626 *
627 * @param array<int,string> $args Positional.
628 * @param array<string,string> $assoc_args Flags.
629 */
630 public function cli_scan_handler( array $args, array $assoc_args ): void {
631 $action = $args[0] ?? 'status';
632
633 if ( 'fixes' === $action ) {
634 $latest = Scan::latest();
635 if ( null === $latest || empty( $latest['fixes'] ) ) {
636 \WP_CLI::log( 'No scan result yet. Run `wp xspeed scan run --wait` first.' );
637 return;
638 }
639 $rows = array();
640 foreach ( $latest['fixes'] as $f ) {
641 $rows[] = array(
642 'check' => $f['id'] . ' ' . $f['name'],
643 'status' => $f['status'],
644 'recoverable' => $f['recoverable'],
645 'evidence' => $f['evidence'],
646 );
647 }
648 \WP_CLI\Utils\format_items( 'table', $rows, array( 'check', 'status', 'recoverable', 'evidence' ) );
649 return;
650 }
651
652 if ( 'run' === $action ) {
653 // Said before the call, not after: on an unconnected site this
654 // publishes a report about the user's site to a public list.
655 // Name the remedy too -- connecting the Hub is what makes a
656 // report unlisted, and a warning without it leaves no move.
657 if ( 'private' !== Scan::visibility() || ! Scan::private_supported() ) {
658 \WP_CLI::warning(
659 'This report will be publicly visible at xspeedcache.com, listed under your domain. '
660 . 'Connect xSpeed Hub from the dashboard to keep your reports unlisted.'
661 );
662 }
663
664 $started = Scan::start(
665 (string) ( $assoc_args['target'] ?? '' ),
666 ! empty( $assoc_args['fresh'] )
667 );
668 if ( is_wp_error( $started ) ) {
669 \WP_CLI::error( $started->get_error_message() );
670 return;
671 }
672 \WP_CLI::log( 'Scan started: ' . $started['scan_id'] );
673 \WP_CLI::log( 'Report: ' . $started['report_url'] );
674
675 if ( empty( $assoc_args['wait'] ) ) {
676 \WP_CLI::log( 'Poll with `wp xspeed scan status`.' );
677 return;
678 }
679
680 // A scan is 20-60s; cap the wait so a stuck engine cannot hang
681 // a CLI session indefinitely.
682 $deadline = time() + 180;
683 while ( time() < $deadline ) {
684 sleep( 5 );
685 $polled = Scan::poll( (string) $started['scan_id'] );
686 if ( is_wp_error( $polled ) ) {
687 \WP_CLI::error( $polled->get_error_message() );
688 return;
689 }
690 if ( ! isset( $polled['status'] ) || 'running' !== $polled['status'] ) {
691 self::cli_print_scan( $polled );
692 return;
693 }
694 \WP_CLI::log( ' ' . ( $polled['step'] ?: 'working' ) . '...' );
695 }
696 \WP_CLI::warning( 'Still running. Poll with `wp xspeed scan status`.' );
697 return;
698 }
699
700 // status
701 $pending = Scan::pending();
702 if ( null !== $pending ) {
703 $polled = Scan::poll( (string) $pending['scan_id'] );
704 if ( is_wp_error( $polled ) ) {
705 \WP_CLI::error( $polled->get_error_message() );
706 return;
707 }
708 if ( isset( $polled['status'] ) && 'running' === $polled['status'] ) {
709 \WP_CLI::log( 'Running: ' . ( $polled['step'] ?: 'working' ) );
710 return;
711 }
712 self::cli_print_scan( $polled );
713 return;
714 }
715
716 $latest = Scan::latest();
717 if ( null === $latest ) {
718 \WP_CLI::log( 'No scan yet. Run `wp xspeed scan run --wait`.' );
719 return;
720 }
721 self::cli_print_scan( $latest );
722 }
723
724 /** Shared rendering for a completed scan. */
725 private static function cli_print_scan( array $r ): void {
726 \WP_CLI::log(
727 sprintf(
728 'Score %s/100 grade %s (%s)%s',
729 null === $r['score'] ? '-' : $r['score'],
730 $r['grade'] ?: '-',
731 $r['level_name'] ?: '-',
732 ! empty( $r['partial'] ) ? ' [partial scan]' : ''
733 )
734 );
735 foreach ( (array) ( $r['dimensions'] ?? array() ) as $key => $d ) {
736 \WP_CLI::log( sprintf( ' %-9s %3s/100 (%s of %s pts)', $key, $d['score'] ?? '-', $d['earned'] ?? '-', $d['weight'] ?? '-' ) );
737 }
738 $lh = $r['measured']['lighthouse'] ?? null;
739 $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.
745 \WP_CLI::log(
746 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'
750 )
751 );
752 }
753 \WP_CLI::log( 'Report: ' . ( $r['report_url'] ?? '' ) );
754 }
755
756 public function cli_handler( array $args, array $assoc ): void {
757 $action = isset( $args[0] ) ? (string) $args[0] : 'status';
758 $opts = Settings_Manager::get( self::SLUG );
759
760 /*
761 * Hub-powered GTmetrix. Handled before the `enabled` gate below: this
762 * path does not use the site's own key or settings at all — it asks
763 * the Hub the site is already connected to, and the Hub owns the
764 * GTmetrix account.
765 */
766 if ( 'hub-test' === $action ) {
767 if ( ! self::hub_speed_test_enabled() ) {
768 \WP_CLI::error( 'Hub-run speed tests are not enabled on this site.' );
769 return;
770 }
771 $result = Mcp_Hub::gtmetrix_test();
772 if ( is_wp_error( $result ) ) {
773 \WP_CLI::error( $result->get_error_message() );
774 return;
775 }
776 $quota = isset( $result['quota'] ) && is_array( $result['quota'] ) ? $result['quota'] : array();
777 \WP_CLI::success(
778 sprintf(
779 'Test started. %s left this month.',
780 isset( $quota['remaining'] ) ? (string) (int) $quota['remaining'] : '?'
781 )
782 );
783 \WP_CLI::log( 'Results appear in the dashboard when the test finishes (about a minute).' );
784 return;
785 }
786
787 if ( 'history' === $action ) {
788 $runs = Score::history();
789 if ( empty( $runs ) ) {
790 \WP_CLI::log( 'No runs recorded yet.' );
791 return;
792 }
793 foreach ( $runs as $run ) {
794 \WP_CLI::log(
795 sprintf(
796 '%s %-9s %-8s %s',
797 gmdate( 'Y-m-d H:i', (int) $run['ts'] ),
798 (string) ( $run['provider'] ?? '' ),
799 empty( $run['ok'] ) ? 'FAILED' : ( null === ( $run['score'] ?? null ) ? 'no score' : $run['score'] . '/100' ),
800 empty( $run['ok'] ) ? (string) ( $run['error'] ?? '' ) : (string) ( $run['url'] ?? '' )
801 )
802 );
803 }
804 return;
805 }
806
807 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 }
812
813 $url = $this->resolve_url( isset( $assoc['target'] ) ? (string) $assoc['target'] : '', $opts );
814 $provider = isset( $assoc['provider'] ) ? (string) $assoc['provider'] : (string) $opts['provider'];
815
816 if ( 'gtmetrix' === $provider ) {
817 $started = Score::start_gtmetrix( $url, (string) $opts['gtmetrix_api_key'] );
818 if ( is_wp_error( $started ) ) {
819 \WP_CLI::error( $started->get_error_message() );
820 return;
821 }
822 \WP_CLI::success( sprintf( 'GTmetrix test queued (id %s). Poll with: wp xspeed score status', (string) ( $started['test_id'] ?? '?' ) ) );
823 return;
824 }
825
826 $strategy = isset( $assoc['strategy'] ) ? (string) $assoc['strategy'] : (string) $opts['default_strategy'];
827 $run = Score::run_psi( $url, $strategy, (string) $opts['psi_api_key'] );
828
829 if ( empty( $run['ok'] ) ) {
830 \WP_CLI::error( (string) $run['error'] );
831 return;
832 }
833 \WP_CLI::success(
834 sprintf(
835 '%s (%s): %s',
836 $url,
837 $strategy,
838 null === $run['score'] ? 'no score returned' : $run['score'] . '/100'
839 )
840 );
841 $this->print_metrics( is_array( $run['metrics'] ) ? $run['metrics'] : array() );
842 return;
843 }
844
845 // status
846 $pending = get_option( Score::PENDING_OPTION, array() );
847 if ( $this->may_poll( $opts, $pending ) ) {
848 $polled = Score::poll_gtmetrix( (string) $opts['gtmetrix_api_key'] );
849 if ( is_wp_error( $polled ) ) {
850 \WP_CLI::error( $polled->get_error_message() );
851 return;
852 }
853 if ( ! empty( $polled['pending'] ) ) {
854 \WP_CLI::log( sprintf( 'GTmetrix test %s is %s.', (string) $pending['test_id'], (string) ( $polled['state'] ?? 'running' ) ) );
855 return;
856 }
857 }
858
859 \WP_CLI::log( 'enabled ' . ( empty( $opts['enabled'] ) ? 'no' : 'yes' ) );
860 \WP_CLI::log( 'provider ' . (string) $opts['provider'] );
861
862 $latest = Score::latest();
863 if ( null === $latest ) {
864 \WP_CLI::log( 'No successful run yet. Run one with: wp xspeed score run' );
865 return;
866 }
867 \WP_CLI::log(
868 sprintf(
869 'latest %s — %s (%s)',
870 null === $latest['score'] ? 'no score' : $latest['score'] . '/100',
871 gmdate( 'Y-m-d H:i', (int) $latest['ts'] ),
872 (string) $latest['provider']
873 )
874 );
875 $this->print_metrics( is_array( $latest['metrics'] ) ? $latest['metrics'] : array() );
876 }
877
878 /**
879 * @param array<string,mixed> $metrics Metric name → value.
880 */
881 private function print_metrics( array $metrics ): void {
882 foreach ( $metrics as $name => $value ) {
883 if ( null === $value ) {
884 continue;
885 }
886 \WP_CLI::log(
887 sprintf(
888 ' %-5s %-10s %s',
889 strtoupper( (string) $name ),
890 'cls' === $name ? (string) round( (float) $value, 3 ) : (int) $value . 'ms',
891 Score::rate( (string) $name, (float) $value )
892 )
893 );
894 }
895 }
896 }
897