diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d94a9e..a2200fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,5 @@ All notable changes to this project are documented in this file, following [Keep ### Added +- `Analysis::report()` returns a `Report` value object (scores + Core Web Vitals) - Support for webmozart/assert 2.x diff --git a/README.md b/README.md index 5aba52c..090b644 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,39 @@ $analysis = $pageSpeedApi->analyse('https://example.com/', categories: 'performa | `locale` | The locale to use for the analysis. | `en` | | `categories` | The categories to analyze. If not specified, all categories will be analyzed. | - | +## Report + +A display-ready view of an analysis: category scores and Core Web Vitals reached by +property, serialisable to array or JSON. + +```php +use PageSpeed\Api\PageSpeedApi; + +$pageSpeedApi = new PageSpeedApi(); +$report = $pageSpeedApi->analyse('https://www.example.com')->report(); + +// Scores (null when the category was not analysed) +$report->performance->value; // 88 +$report->performance->rating; // Rating::NeedsImprovement +$report->performance->rating->value; // 'needs-improvement' +$report->seo->value; // 90 + +// Core Web Vitals (null when field data is unavailable) +$report->lcp->value; // 2300 +$report->lcp->unit; // 'ms' +$report->lcp->rating; // Bucket::Fast + +// Serialise for a template, a CLI or an API +$report->toArray(); +json_encode($report); +``` + +Scores (`performance`, `accessibility`, `bestPractices`, `seo`) expose `value` (0-100), +`rating` (a `Rating` following the [Score Evaluation](#score-evaluation) thresholds), and +`category` (`Category`). Metrics (`lcp`, `cls`, `inp`, `fcp`, `fid`, `ttfb`) expose `value`, +`unit`, and `rating` (a `Bucket`: `Fast`, `Average` or `Slow`). Any property is `null` when +the underlying data is absent. + ## Audit Scores ![audit-scores.png](docs/audit-scores.png) diff --git a/src/Analysis.php b/src/Analysis.php index 1919afb..9fcf011 100644 --- a/src/Analysis.php +++ b/src/Analysis.php @@ -16,6 +16,7 @@ use DateTimeImmutable; use PageSpeed\Api\Analysis\LighthouseResult; use PageSpeed\Api\Analysis\LoadingExperience; +use PageSpeed\Api\Report\Report; use Webmozart\Assert\Assert; final readonly class Analysis @@ -73,6 +74,11 @@ public function getId(): string return $this->id; } + public function report(): Report + { + return Report::fromAnalysis($this); + } + /** * @return array */ diff --git a/src/Report/Bucket.php b/src/Report/Bucket.php new file mode 100644 index 0000000..a204373 --- /dev/null +++ b/src/Report/Bucket.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Report; + +/** + * Core Web Vitals rating bucket, mirroring the CrUX metric "category" strings. + */ +enum Bucket: string +{ + case Fast = 'FAST'; + case Average = 'AVERAGE'; + case Slow = 'SLOW'; +} diff --git a/src/Report/MetricValue.php b/src/Report/MetricValue.php new file mode 100644 index 0000000..a87d198 --- /dev/null +++ b/src/Report/MetricValue.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Report; + +/** + * A single Core Web Vitals metric with its field value, unit and rating. + */ +final readonly class MetricValue implements \JsonSerializable +{ + public function __construct( + public string $name, + public int $value, + public string $unit, + public ?Bucket $rating, + ) { + } + + /** + * @return array{name: string, value: int, unit: string, rating: string|null} + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'value' => $this->value, + 'unit' => $this->unit, + 'rating' => $this->rating?->value, + ]; + } + + /** + * @return array{name: string, value: int, unit: string, rating: string|null} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Report/Rating.php b/src/Report/Rating.php new file mode 100644 index 0000000..f502af9 --- /dev/null +++ b/src/Report/Rating.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Report; + +/** + * Lighthouse category score rating, following the PageSpeed thresholds. + */ +enum Rating: string +{ + case Poor = 'poor'; + case NeedsImprovement = 'needs-improvement'; + case Good = 'good'; + + public static function fromScore(int $value): self + { + return match (true) { + $value >= 90 => self::Good, + $value >= 50 => self::NeedsImprovement, + default => self::Poor, + }; + } +} diff --git a/src/Report/Report.php b/src/Report/Report.php new file mode 100644 index 0000000..18f5630 --- /dev/null +++ b/src/Report/Report.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Report; + +use PageSpeed\Api\Analysis; +use PageSpeed\Api\Analysis\Category; + +/** + * Display-ready view over an Analysis: category scores and Core Web Vitals, + * reachable by property and serialisable to array/JSON. + */ +final readonly class Report implements \JsonSerializable +{ + /** + * CrUX metric key => [short name, unit]. + * + * @var array + */ + private const array METRICS = [ + 'LARGEST_CONTENTFUL_PAINT_MS' => ['lcp', 'ms'], + 'CUMULATIVE_LAYOUT_SHIFT_SCORE' => ['cls', ''], + 'INTERACTION_TO_NEXT_PAINT' => ['inp', 'ms'], + 'FIRST_CONTENTFUL_PAINT_MS' => ['fcp', 'ms'], + 'FIRST_INPUT_DELAY_MS' => ['fid', 'ms'], + 'EXPERIMENTAL_TIME_TO_FIRST_BYTE' => ['ttfb', 'ms'], + ]; + + public function __construct( + public ?Score $performance, + public ?Score $accessibility, + public ?Score $bestPractices, + public ?Score $seo, + public ?MetricValue $lcp, + public ?MetricValue $cls, + public ?MetricValue $inp, + public ?MetricValue $fcp, + public ?MetricValue $fid, + public ?MetricValue $ttfb, + ) { + } + + public static function fromAnalysis(Analysis $analysis): self + { + $scores = []; + foreach ($analysis->getAuditScores() as $key => $value) { + $category = Category::tryFrom($key); + if (null !== $category) { + $scores[$key] = new Score($category, $value); + } + } + + $metrics = []; + foreach ($analysis->loadingExperience->metrics ?? [] as $key => $metric) { + $definition = self::METRICS[$key] ?? null; + if (null === $definition) { + continue; + } + [$name, $unit] = $definition; + $metrics[$name] = new MetricValue($name, $metric->percentile, $unit, Bucket::tryFrom($metric->category)); + } + + return new self( + performance: $scores['performance'] ?? null, + accessibility: $scores['accessibility'] ?? null, + bestPractices: $scores['best-practices'] ?? null, + seo: $scores['seo'] ?? null, + lcp: $metrics['lcp'] ?? null, + cls: $metrics['cls'] ?? null, + inp: $metrics['inp'] ?? null, + fcp: $metrics['fcp'] ?? null, + fid: $metrics['fid'] ?? null, + ttfb: $metrics['ttfb'] ?? null, + ); + } + + /** + * @return array{ + * scores: array, + * metrics: array, + * } + */ + public function toArray(): array + { + return [ + 'scores' => [ + 'performance' => $this->performance?->toArray(), + 'accessibility' => $this->accessibility?->toArray(), + 'best-practices' => $this->bestPractices?->toArray(), + 'seo' => $this->seo?->toArray(), + ], + 'metrics' => [ + 'lcp' => $this->lcp?->toArray(), + 'cls' => $this->cls?->toArray(), + 'inp' => $this->inp?->toArray(), + 'fcp' => $this->fcp?->toArray(), + 'fid' => $this->fid?->toArray(), + 'ttfb' => $this->ttfb?->toArray(), + ], + ]; + } + + /** + * @return array{ + * scores: array, + * metrics: array, + * } + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Report/Score.php b/src/Report/Score.php new file mode 100644 index 0000000..dfcdf2a --- /dev/null +++ b/src/Report/Score.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Report; + +use PageSpeed\Api\Analysis\Category; + +/** + * A single Lighthouse category score (0-100) with its rating. + */ +final readonly class Score implements \JsonSerializable +{ + public Rating $rating; + + public function __construct( + public Category $category, + public int $value, + ) { + $this->rating = Rating::fromScore($value); + } + + /** + * @return array{category: string, value: int, rating: string} + */ + public function toArray(): array + { + return [ + 'category' => $this->category->value, + 'value' => $this->value, + 'rating' => $this->rating->value, + ]; + } + + /** + * @return array{category: string, value: int, rating: string} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/tests/AnalysisTest.php b/tests/AnalysisTest.php index fd8d2a4..d817d8e 100644 --- a/tests/AnalysisTest.php +++ b/tests/AnalysisTest.php @@ -138,4 +138,17 @@ public function testGetOriginalLoadingMetricsReturnsCorrectValues(): void self::assertSame($expectedMetrics, $analysis->getOriginalLoadingMetrics()); } + public function testReport(): void + { + $analysis = Analysis::create(AnalysisFactory::createData([ + 'lighthouseResult' => ['categories' => [ + 'performance' => LighthouseCategoryResultFactory::createData(['id' => 'performance', 'score' => 0.9]), + ]], + ])); + + $report = $analysis->report(); + + self::assertNotNull($report->performance); + self::assertSame(90, $report->performance->value); + } } diff --git a/tests/Report/BucketTest.php b/tests/Report/BucketTest.php new file mode 100644 index 0000000..d0e4725 --- /dev/null +++ b/tests/Report/BucketTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Tests\Report; + +use PageSpeed\Api\Report\Bucket; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Bucket::class)] +final class BucketTest extends TestCase +{ + public function testBackingValuesMirrorCruxCategories(): void + { + self::assertSame('FAST', Bucket::Fast->value); + self::assertSame('AVERAGE', Bucket::Average->value); + self::assertSame('SLOW', Bucket::Slow->value); + } + + public function testMapsFromCruxCategory(): void + { + self::assertSame(Bucket::Fast, Bucket::tryFrom('FAST')); + self::assertSame(Bucket::Slow, Bucket::tryFrom('SLOW')); + } +} diff --git a/tests/Report/MetricValueTest.php b/tests/Report/MetricValueTest.php new file mode 100644 index 0000000..a069fc8 --- /dev/null +++ b/tests/Report/MetricValueTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Tests\Report; + +use PageSpeed\Api\Report\Bucket; +use PageSpeed\Api\Report\MetricValue; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(MetricValue::class)] +final class MetricValueTest extends TestCase +{ + public function testExposesNameValueUnitAndRating(): void + { + $metric = new MetricValue('lcp', 2300, 'ms', Bucket::Fast); + + self::assertSame('lcp', $metric->name); + self::assertSame(2300, $metric->value); + self::assertSame('ms', $metric->unit); + self::assertSame(Bucket::Fast, $metric->rating); + } + + public function testToArray(): void + { + self::assertSame( + ['name' => 'lcp', 'value' => 2300, 'unit' => 'ms', 'rating' => 'FAST'], + (new MetricValue('lcp', 2300, 'ms', Bucket::Fast))->toArray(), + ); + } + + public function testToArrayWithUnknownRating(): void + { + self::assertSame( + ['name' => 'cls', 'value' => 10, 'unit' => '', 'rating' => null], + (new MetricValue('cls', 10, '', null))->toArray(), + ); + } + + public function testJsonSerializes(): void + { + self::assertSame( + '{"name":"lcp","value":2300,"unit":"ms","rating":"FAST"}', + json_encode(new MetricValue('lcp', 2300, 'ms', Bucket::Fast)), + ); + } +} diff --git a/tests/Report/RatingTest.php b/tests/Report/RatingTest.php new file mode 100644 index 0000000..0524a4a --- /dev/null +++ b/tests/Report/RatingTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Tests\Report; + +use PageSpeed\Api\Report\Rating; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Rating::class)] +final class RatingTest extends TestCase +{ + public function testBackingValues(): void + { + self::assertSame('poor', Rating::Poor->value); + self::assertSame('needs-improvement', Rating::NeedsImprovement->value); + self::assertSame('good', Rating::Good->value); + } + + #[DataProvider('scoreBoundaries')] + public function testFromScore(int $score, Rating $expected): void + { + self::assertSame($expected, Rating::fromScore($score)); + } + + /** + * @return iterable + */ + public static function scoreBoundaries(): iterable + { + yield 'floor' => [0, Rating::Poor]; + yield 'poor upper bound' => [49, Rating::Poor]; + yield 'needs-improvement lower bound' => [50, Rating::NeedsImprovement]; + yield 'needs-improvement upper bound' => [89, Rating::NeedsImprovement]; + yield 'good lower bound' => [90, Rating::Good]; + yield 'ceiling' => [100, Rating::Good]; + } +} diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php new file mode 100644 index 0000000..a7e641d --- /dev/null +++ b/tests/Report/ReportTest.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Tests\Report; + +use PageSpeed\Api\Analysis; +use PageSpeed\Api\Analysis\Category; +use PageSpeed\Api\Report\Bucket; +use PageSpeed\Api\Report\Rating; +use PageSpeed\Api\Report\Report; +use PageSpeed\Api\Tests\Fixtures\Factory\AnalysisFactory; +use PageSpeed\Api\Tests\Fixtures\Factory\LighthouseCategoryResultFactory; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Report::class)] +final class ReportTest extends TestCase +{ + /** + * @param array $overrides + */ + private static function report(array $overrides = []): Report + { + return Analysis::create(AnalysisFactory::createData($overrides))->report(); + } + + public function testScoresAsNamedProperties(): void + { + $report = self::report([ + 'lighthouseResult' => ['categories' => [ + 'performance' => LighthouseCategoryResultFactory::createData(['id' => 'performance', 'score' => 0.75]), + 'seo' => LighthouseCategoryResultFactory::createData(['id' => 'seo', 'title' => 'SEO', 'score' => 0.9]), + ]], + ]); + + self::assertNotNull($report->performance); + self::assertSame(75, $report->performance->value); + self::assertSame(Rating::NeedsImprovement, $report->performance->rating); + self::assertSame(Category::Performance, $report->performance->category); + + self::assertNotNull($report->seo); + self::assertSame(90, $report->seo->value); + self::assertSame(Rating::Good, $report->seo->rating); + } + + public function testAbsentCategoryScoreIsNull(): void + { + $report = self::report([ + 'lighthouseResult' => ['categories' => [ + 'performance' => LighthouseCategoryResultFactory::createData(['id' => 'performance', 'score' => 0.9]), + ]], + ]); + + self::assertNull($report->accessibility); + self::assertNull($report->bestPractices); + } + + public function testMetricsCarryValueUnitAndRating(): void + { + $report = self::report([ + 'loadingExperience' => ['metrics' => [ + 'LARGEST_CONTENTFUL_PAINT_MS' => ['category' => 'FAST', 'distributions' => [], 'percentile' => 2300], + 'CUMULATIVE_LAYOUT_SHIFT_SCORE' => ['category' => 'AVERAGE', 'distributions' => [], 'percentile' => 10], + ]], + ]); + + self::assertNotNull($report->lcp); + self::assertSame('lcp', $report->lcp->name); + self::assertSame(2300, $report->lcp->value); + self::assertSame('ms', $report->lcp->unit); + self::assertSame(Bucket::Fast, $report->lcp->rating); + + self::assertNotNull($report->cls); + self::assertSame(10, $report->cls->value); + self::assertSame('', $report->cls->unit); + self::assertSame(Bucket::Average, $report->cls->rating); + } + + public function testOnlyKnownCruxKeysAreMapped(): void + { + // The default fixture supplies lowercase keys (e.g. first_contentful_paint); + // only the real ALL_CAPS CrUX keys map, so fcp stays null here. + $report = self::report(); + + self::assertNull($report->fcp); + self::assertNull($report->ttfb); + } + + public function testUnknownMetricRatingIsNull(): void + { + $report = self::report([ + 'loadingExperience' => ['metrics' => [ + 'INTERACTION_TO_NEXT_PAINT' => ['category' => 'NONE', 'distributions' => [], 'percentile' => 200], + ]], + ]); + + self::assertNotNull($report->inp); + self::assertSame(200, $report->inp->value); + self::assertNull($report->inp->rating); + } + + public function testNoFieldDataYieldsNullMetrics(): void + { + $report = self::report(['loadingExperience' => null]); + + self::assertNull($report->lcp); + self::assertNull($report->cls); + self::assertNull($report->inp); + self::assertNull($report->fcp); + self::assertNull($report->fid); + self::assertNull($report->ttfb); + } + + public function testToArrayShape(): void + { + $report = self::report([ + 'lighthouseResult' => ['categories' => [ + 'performance' => LighthouseCategoryResultFactory::createData(['id' => 'performance', 'score' => 0.9]), + ]], + 'loadingExperience' => ['metrics' => [ + 'LARGEST_CONTENTFUL_PAINT_MS' => ['category' => 'FAST', 'distributions' => [], 'percentile' => 2300], + ]], + ]); + + $array = $report->toArray(); + + self::assertSame(['category' => 'performance', 'value' => 90, 'rating' => 'good'], $array['scores']['performance']); + self::assertNull($array['scores']['seo']); + self::assertSame(['name' => 'lcp', 'value' => 2300, 'unit' => 'ms', 'rating' => 'FAST'], $array['metrics']['lcp']); + self::assertNull($array['metrics']['ttfb']); + } + + public function testJsonEncodesViaToArray(): void + { + $report = self::report([ + 'loadingExperience' => ['metrics' => [ + 'LARGEST_CONTENTFUL_PAINT_MS' => ['category' => 'FAST', 'distributions' => [], 'percentile' => 2300], + ]], + ]); + + self::assertSame(json_encode($report->toArray()), json_encode($report)); + } +} diff --git a/tests/Report/ScoreTest.php b/tests/Report/ScoreTest.php new file mode 100644 index 0000000..40d6b52 --- /dev/null +++ b/tests/Report/ScoreTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PageSpeed\Api\Tests\Report; + +use PageSpeed\Api\Analysis\Category; +use PageSpeed\Api\Report\Rating; +use PageSpeed\Api\Report\Score; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Score::class)] +final class ScoreTest extends TestCase +{ + public function testExposesCategoryAndValue(): void + { + $score = new Score(Category::Performance, 88); + + self::assertSame(Category::Performance, $score->category); + self::assertSame(88, $score->value); + } + + public function testDerivesRatingFromValue(): void + { + self::assertSame(Rating::NeedsImprovement, (new Score(Category::Seo, 88))->rating); + self::assertSame(Rating::Good, (new Score(Category::Seo, 90))->rating); + self::assertSame(Rating::Poor, (new Score(Category::Seo, 10))->rating); + } + + public function testToArray(): void + { + self::assertSame( + ['category' => 'performance', 'value' => 88, 'rating' => 'needs-improvement'], + (new Score(Category::Performance, 88))->toArray(), + ); + } + + public function testJsonSerializes(): void + { + self::assertSame( + '{"category":"performance","value":88,"rating":"needs-improvement"}', + json_encode(new Score(Category::Performance, 88)), + ); + } +}