Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 88 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

Pest plugin for checking that Laravel Blade views are rendered by your test suite.

It is built for package-heavy Laravel applications where normal PHP coverage excludes
`resources/views/**/*.blade.php`. The plugin records views that Laravel actually renders,
then compares uncovered views against a committed hash baseline so CI only fails for new
or changed uncovered Blade files.
Normal PHP coverage never reports `resources/views/**/*.blade.php`, because Blade compiles
to PHP elsewhere before it runs. This plugin fills that gap: it records the views Laravel
actually renders during your tests, then compares uncovered views against a committed hash
baseline so CI only fails for new or changed uncovered Blade files.

It works out of the box on a standard Laravel application (`resources/views`), and a single
`include` entry extends it to package-per-directory monorepos.

## Install

Expand All @@ -28,7 +31,10 @@ If the package is not available through Packagist yet, add a VCS repository firs

## Configure

Create `tests/blade-coverage.php`:
Configuration is optional. With no `tests/blade-coverage.php` the plugin scans
`resources/views/**/*.blade.php`, so it works out of the box on a standard Laravel app.

To customise, create `tests/blade-coverage.php`:

```php
<?php
Expand All @@ -37,14 +43,37 @@ declare(strict_types=1);

return [
'include' => [
'packages/*/resources/views/**/*.blade.php',
'resources/views/**/*.blade.php',
],
'exclude' => [],
'baseline' => 'tests/BladeCoverage/baseline.json',
'cache' => '.cache/pest-blade-coverage',

// How uncovered views are grouped in the console output:
// 'auto' (default) | 'package' | 'directory' | 'flat'.
'group_by' => 'auto',

// 'baseline' (default) ratchets against the committed baseline.
// 'strict' ignores the baseline and fails on ANY uncovered view.
'mode' => 'baseline',

// Optionally fail when the covered percentage drops below this threshold.
'min_coverage' => null,
];
```

### Monorepos

For a package-per-directory monorepo, add the package views to `include` (the `auto`
grouping then labels failures by package name):

```php
'include' => [
'resources/views/**/*.blade.php',
'packages/*/resources/views/**/*.blade.php',
],
```

Add a Composer script:

```json
Expand Down Expand Up @@ -98,16 +127,62 @@ vendor/bin/pest --blade-coverage --blade-coverage-json=coverage/blade-coverage.j
- New uncovered views and changed uncovered views fail the run.
- Parallel Pest runs are supported through JSON shards in the configured cache directory.

## Ignoring Individual Views

Add the ignore marker inside a Blade file (typically as a comment) to drop it from
coverage entirely, without touching `exclude` globs:

```blade
{{-- blade-coverage:ignore --}}
```

Co-locating the marker keeps the decision next to the view, so it survives moves and
renames that a path-based exclude would not.

## Failure Policy

- **Baseline (default):** new and changed uncovered views fail; baseline-matched
uncovered views are allowed.
- **Strict (`'mode' => 'strict'`):** the baseline is ignored and *any* uncovered view
fails. Good for a green-field package that should keep 100% of views rendered.
- **Minimum coverage (`'min_coverage' => 90`):** additionally fail when the covered
percentage drops below the threshold. Combines with either mode.

## GitHub Actions Integration

When the run detects GitHub Actions (`GITHUB_ACTIONS=true`) it automatically:

- emits `::error` workflow-command annotations for each new/changed uncovered view, so
they appear inline on the pull request diff, and
- appends a coverage summary table to `$GITHUB_STEP_SUMMARY`.

No extra flags are required; it is a no-op outside of GitHub Actions.

## Programmatic Use

Capture the views a code path renders from within a single test, independent of the
suite-wide run:

```php
use Capell\PestBladeCoverage\BladeCoverage;

$rendered = BladeCoverage::capture(fn () => view('dashboard')->render());
// ['resources/views/dashboard.blade.php']

expect(BladeCoverage::rendered('resources/views/dashboard.blade.php',
fn () => $this->get('/dashboard')))->toBeTrue();
```

## Failure Examples

If a package adds a Blade file but no test renders it:
If you add a Blade file but no test renders it:

```text
Blade view coverage
1 covered, 0 baseline-allowed, 1 new uncovered, 0 changed uncovered, 2 total
1 covered, 0 baseline-allowed, 1 new uncovered, 0 changed uncovered, 2 total (50.0% covered)
New uncovered Blade views:
blog:
- packages/blog/resources/views/sidebar.blade.php
resources/views:
- resources/views/sidebar.blade.php
```

This fails the Pest process with exit code `1`.
Expand All @@ -117,10 +192,10 @@ render coverage:

```text
Blade view coverage
0 covered, 0 baseline-allowed, 0 new uncovered, 1 changed uncovered, 1 total
0 covered, 0 baseline-allowed, 0 new uncovered, 1 changed uncovered, 1 total (0.0% covered)
Changed uncovered Blade views:
blog:
- packages/blog/resources/views/sidebar.blade.php
resources/views:
- resources/views/sidebar.blade.php
```

This also fails with exit code `1`. Fix either case by adding a test that renders the
Expand Down
44 changes: 44 additions & 0 deletions src/BladeCoverage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Capell\PestBladeCoverage;

/**
* Programmatic entry point for capturing Blade render coverage outside of a
* full Pest run — useful for asserting that a specific code path renders (or
* does not render) a given view from within a single test.
*/
final class BladeCoverage
{
/**
* Run the callback with a Blade render collector armed and return the
* relative paths of every view Laravel rendered while it ran.
*
* Self-contained: it resolves the project config (or accepts an explicit
* one) and uses its own recorder, so it does not depend on the Pest plugin
* being active.
*
* @return list<string>
*/
public static function capture(callable $callback, ?BladeCoverageConfig $config = null): array
{
$config ??= (new BladeCoverageConfigLoader)->load();

$recorder = new BladeCoverageRecorder;
(new BladeViewRenderCollector($recorder))->arm($config);

$callback();

return $recorder->covered();
}

/**
* Whether the given view (by relative path) was rendered while the callback
* ran.
*/
public static function rendered(string $view, callable $callback, ?BladeCoverageConfig $config = null): bool
{
return in_array(Path::normalize($view), self::capture($callback, $config), true);
}
}
40 changes: 38 additions & 2 deletions src/BladeCoverageConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@

final readonly class BladeCoverageConfig
{
/**
* Default include patterns. Targets the standard Laravel application view
* directory so the plugin works with zero configuration on a normal app.
* Monorepos can add their package view globs via the `include` config.
*
* @var list<string>
*/
public const array DEFAULT_INCLUDE = [
'resources/views/**/*.blade.php',
];

public const string MODE_BASELINE = 'baseline';

public const string MODE_STRICT = 'strict';

/** @var list<string> */
private const array GROUP_MODES = ['auto', 'package', 'directory', 'flat'];

/**
* @param list<string> $include
* @param list<string> $exclude
Expand All @@ -16,6 +34,9 @@ public function __construct(
public array $exclude,
public string $baselinePath,
public string $cachePath,
public string $groupBy = 'auto',
public string $mode = self::MODE_BASELINE,
public ?int $minCoverage = null,
) {}

/**
Expand All @@ -25,13 +46,28 @@ public static function fromArray(array $config, string $rootPath): self
{
$rootPath = Path::normalize($rootPath);

$include = self::stringList($config['include'] ?? ['packages/*/resources/views/**/*.blade.php']);
$include = self::stringList($config['include'] ?? self::DEFAULT_INCLUDE);
$exclude = self::stringList($config['exclude'] ?? []);

$baselinePath = self::resolvePath($rootPath, self::stringValue($config['baseline'] ?? $config['baseline_path'] ?? null, 'tests/BladeCoverage/baseline.json'));
$cachePath = self::resolvePath($rootPath, self::stringValue($config['cache'] ?? $config['cache_path'] ?? null, '.cache/pest-blade-coverage'));

return new self($rootPath, $include, $exclude, $baselinePath, $cachePath);
$groupBy = self::stringValue($config['group_by'] ?? null, 'auto');
$groupBy = in_array($groupBy, self::GROUP_MODES, true) ? $groupBy : 'auto';

$mode = self::stringValue($config['mode'] ?? null, self::MODE_BASELINE);
$mode = $mode === self::MODE_STRICT ? self::MODE_STRICT : self::MODE_BASELINE;

return new self($rootPath, $include, $exclude, $baselinePath, $cachePath, $groupBy, $mode, self::minCoverage($config['min_coverage'] ?? null));
}

private static function minCoverage(mixed $value): ?int
{
if (! is_int($value) && ! (is_string($value) && is_numeric($value))) {
return null;
}

return max(0, min(100, (int) $value));
}

public function fingerprint(): string
Expand Down
8 changes: 5 additions & 3 deletions src/BladeCoverageJsonReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@

final readonly class BladeCoverageJsonReport
{
public function write(string $path, BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath): void
public function write(string $path, BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath, ?bool $failed = null): void
{
$failed ??= $result->failed();
$directory = dirname($path);

if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) {
Expand All @@ -31,15 +32,16 @@ public function write(string $path, BladeCoverageResult $result, bool $baselineU
'baselineAllowed' => count($result->baselineAllowed),
'newUncovered' => count($result->newUncovered),
'changedUncovered' => count($result->changedUncovered),
'failed' => $result->failed(),
'coveragePercentage' => $result->coveragePercentage(),
'failed' => $failed,
],
'views' => [
'covered' => array_keys($result->covered),
'baselineAllowed' => array_keys($result->baselineAllowed),
'newUncovered' => array_keys($result->newUncovered),
'changedUncovered' => array_keys($result->changedUncovered),
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR);
} catch (JsonException $jsonException) {
throw new RuntimeException(sprintf('Unable to encode Blade coverage JSON report [%s]: %s', $path, $jsonException->getMessage()), previous: $jsonException);
}
Expand Down
37 changes: 10 additions & 27 deletions src/BladeCoverageOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,27 @@

final readonly class BladeCoverageOutput
{
private BladeViewGrouper $grouper;

public function __construct(
private OutputInterface $output,
) {}
string $groupBy = 'auto',
) {
$this->grouper = new BladeViewGrouper($groupBy);
}

public function render(BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath): void
{
$this->output->writeln('');
$this->output->writeln('<options=bold>Blade view coverage</>');
$this->output->writeln(sprintf(
' %d covered, %d baseline-allowed, %d new uncovered, %d changed uncovered, %d total',
' %d covered, %d baseline-allowed, %d new uncovered, %d changed uncovered, %d total (%.1f%% covered)',
count($result->covered),
count($result->baselineAllowed),
count($result->newUncovered),
count($result->changedUncovered),
count($result->targets),
$result->coveragePercentage(),
));

if ($baselineUpdated) {
Expand Down Expand Up @@ -63,8 +69,8 @@ private function renderFailures(string $label, array $targets): void

$rendered = 0;

foreach ($this->groupTargetsByPackage($targets) as $package => $paths) {
$this->output->writeln(sprintf(' %s:', $package));
foreach ($this->grouper->group($targets) as $group => $paths) {
$this->output->writeln(sprintf(' %s:', $group));

foreach ($paths as $path) {
if ($rendered >= 25) {
Expand All @@ -80,27 +86,4 @@ private function renderFailures(string $label, array $targets): void
$this->output->writeln(sprintf(' ... and %d more', count($targets) - $rendered));
}
}

/**
* @param array<string, BladeViewTarget> $targets
* @return array<string, list<string>>
*/
private function groupTargetsByPackage(array $targets): array
{
$groups = [];

foreach (array_keys($targets) as $path) {
$segments = explode('/', $path);
$package = count($segments) >= 2 && $segments[0] === 'packages'
? $segments[1]
: 'other';

$groups[$package] ??= [];
$groups[$package][] = $path;
}

ksort($groups);

return $groups;
}
}
Loading