PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / modules / Score / ScoreModule.php

ScoreModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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