| @@ -102,8 +102,22 @@ | ||
| 102 | 102 | return (bool) apply_filters( 'xspeed_scan_private_supported', true ); |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | 105 | /** |
| 106 | + * Which Lighthouse runs a scan may spend. `both` is the engine's default: | |
| 107 | + * the desktop run graded as the headline, the mobile run graded alongside | |
| 108 | + * it. A single device costs half the engine's quota and becomes the | |
| 109 | + * headline. Anything else is sent as `both`. | |
| 110 | + */ | |
| 111 | + const STRATEGIES = array( 'both', 'desktop', 'mobile' ); | |
| 112 | + | |
| 113 | + /** Coerce a caller's strategy to one the engine knows. */ | |
| 114 | + public static function strategy( string $value ): string { | |
| 115 | + $value = strtolower( trim( $value ) ); | |
| 116 | + return in_array( $value, self::STRATEGIES, true ) ? $value : 'both'; | |
| 117 | + } | |
| 118 | + | |
| 119 | + /** | |
| 106 | 120 | * Start a scan. Returns the scan id to poll, or a WP_Error. |
| 107 | 121 | * |
| 108 | 122 | * `$fresh` forces a new run; without it the engine may hand back a |
| 109 | 123 | * recent cached report for the same URL, which is what you want for a |
| @@ -108,11 +122,16 @@ | ||
| 108 | 122 | * `$fresh` forces a new run; without it the engine may hand back a |
| 109 | 123 | * recent cached report for the same URL, which is what you want for a |
| 110 | 124 | * first look and not what you want after a change. |
| 111 | 125 | * |
| 126 | + * `$strategy` picks the Lighthouse runs (see STRATEGIES). The engine | |
| 127 | + * grades the desktop run as the headline and the mobile run beside it, | |
| 128 | + * so `both` is right for a report a person reads; a site that only | |
| 129 | + * wants the run Google ranks on asks for `mobile` and spends half. | |
| 130 | + * | |
| 112 | 131 | * @return array{scan_id:string,report_url:string,cached:bool}|\WP_Error |
| 113 | 132 | */ |
| 114 | - public static function start( string $url = '', bool $fresh = false ) { | |
| 133 | + public static function start( string $url = '', bool $fresh = false, string $strategy = 'both' ) { | |
| 115 | 134 | $url = '' !== $url ? $url : home_url( '/' ); |
| 116 | 135 | |
| 117 | 136 | // The engine probes this URL from the outside, so a host it cannot |
| 118 | 137 | // reach produces a confusing failure deep in the scan rather than |
| @@ -145,10 +164,11 @@ | ||
| 145 | 164 | 'timeout' => 20, |
| 146 | 165 | 'headers' => array( 'Content-Type' => 'application/json' ), |
| 147 | 166 | 'body' => wp_json_encode( |
| 148 | 167 | array( |
| 149 | - 'url' => $url, | |
| 150 | - 'fresh' => $fresh, | |
| 168 | + 'url' => $url, | |
| 169 | + 'fresh' => $fresh, | |
| 170 | + 'strategy' => self::strategy( $strategy ), | |
| 151 | 171 | // Sent ahead of engine support: an unknown field is |
| 152 | 172 | // ignored today and becomes meaningful the moment |
| 153 | 173 | // the flag lands, with no plugin release needed. |
| 154 | 174 | 'visibility' => 'private' === self::visibility() ? 'unlisted' : 'listed', |
| @@ -313,11 +333,43 @@ | ||
| 313 | 333 | |
| 314 | 334 | /** The last completed report, or null. */ |
| 315 | 335 | public static function latest(): ?array { |
| 316 | 336 | $r = get_option( self::RESULT_OPTION ); |
| 317 | - return is_array( $r ) && isset( $r['score'] ) ? $r : null; | |
| 337 | + if ( ! is_array( $r ) || ! isset( $r['score'] ) ) { | |
| 338 | + return null; | |
| 339 | + } | |
| 340 | + return self::with_devices( $r ); | |
| 318 | 341 | } |
| 319 | 342 | |
| 343 | + /** | |
| 344 | + * Give a stored record the per-device Lighthouse shape every reader now | |
| 345 | + * expects, whenever it was written. | |
| 346 | + * | |
| 347 | + * A record written BEFORE the engine graded desktop as the headline has | |
| 348 | + * no `device` and its `lighthouse` is the mobile run — that was the only | |
| 349 | + * run PSI made by default. A record written after has `device` set and | |
| 350 | + * carries both devices explicitly. So `device` is what decides how to | |
| 351 | + * read `lighthouse`, and nothing else has to know the difference. | |
| 352 | + * | |
| 353 | + * Deliberately does NOT invent a missing device: a desktop-only scan | |
| 354 | + * never measured mobile, and showing the desktop number under a mobile | |
| 355 | + * label is exactly what this replaced. | |
| 356 | + */ | |
| 357 | + private static function with_devices( array $r ): array { | |
| 358 | + $m = isset( $r['measured'] ) && is_array( $r['measured'] ) ? $r['measured'] : array(); | |
| 359 | + if ( ! array_key_exists( 'lighthouse_mobile', $m ) ) { | |
| 360 | + $m['lighthouse_mobile'] = '' === (string) ( $r['device'] ?? '' ) | |
| 361 | + ? ( $m['lighthouse'] ?? null ) // pre-change record: it was mobile | |
| 362 | + : null; // post-change: mobile simply wasn't run | |
| 363 | + } | |
| 364 | + if ( ! array_key_exists( 'lighthouse_desktop', $m ) ) { | |
| 365 | + $m['lighthouse_desktop'] = null; | |
| 366 | + } | |
| 367 | + $r['measured'] = $m; | |
| 368 | + $r['device'] = (string) ( $r['device'] ?? '' ); | |
| 369 | + return $r; | |
| 370 | + } | |
| 371 | + | |
| 320 | 372 | public static function report_url( string $scan_id ): string { |
| 321 | 373 | return self::base() . '/scan/r/' . $scan_id; |
| 322 | 374 | } |
| 323 | 375 | |
| @@ -393,12 +445,25 @@ | ||
| 393 | 445 | // A partial scan graded less than the full rubric; saying so is |
| 394 | 446 | // the difference between a low score and an incomplete one. |
| 395 | 447 | 'partial' => ! empty( $body['partial'] ), |
| 396 | 448 | 'dimensions' => $dims, |
| 449 | + // Which device the headline grade is for: `desktop` (the default | |
| 450 | + // `both` run grades desktop as the headline), `mobile`, or '' | |
| 451 | + // from an engine that predates the strategy option. | |
| 452 | + 'device' => isset( $body['device'] ) ? (string) $body['device'] : '', | |
| 397 | 453 | 'measured' => array( |
| 398 | 454 | // Lighthouse is reported alongside, never AS, the score. |
| 455 | + // | |
| 456 | + // `lighthouse` is the GRADED run's score, and which device | |
| 457 | + // that is changed when the engine started grading desktop as | |
| 458 | + // the headline. Read the two per-device fields instead; this | |
| 459 | + // one stays only so a stored record keeps its shape, and | |
| 460 | + // `latest()` back-fills it for records written before the | |
| 461 | + // change. Reading it AS the mobile score is the bug this | |
| 462 | + // comment exists to prevent (it printed desktop as mobile). | |
| 399 | 463 | 'lighthouse' => self::num( $measured['lighthouse'] ?? null ), |
| 400 | 464 | 'lighthouse_desktop' => self::num( $measured['lighthouseDesktop'] ?? null ), |
| 465 | + 'lighthouse_mobile' => self::num( $measured['lighthouseMobile'] ?? null ), | |
| 401 | 466 | 'ttfb_ms' => self::num( $measured['ttfbMs'] ?? null ), |
| 402 | 467 | 'lcp_ms' => self::num( $measured['lcpMs'] ?? null ), |
| 403 | 468 | 'cls' => self::num( $measured['cls'] ?? null ), |
| 404 | 469 | 'tbt_ms' => self::num( $measured['tbtMs'] ?? null ), |