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
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ 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 both
`resources/views/**/*.blade.php` and `packages/*/resources/views/**/*.blade.php`, so it
works out of the box on a standard application or a package-per-directory monorepo.

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

```php
<?php
Expand All @@ -37,11 +41,23 @@ declare(strict_types=1);

return [
'include' => [
'resources/views/**/*.blade.php',
'packages/*/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,
];
```

Expand Down Expand Up @@ -98,6 +114,52 @@ 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('blog::index')->render());
// ['packages/blog/resources/views/index.blade.php']

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

## Failure Examples

If a package adds a Blade file but no test renders it:
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);
}
}
41 changes: 39 additions & 2 deletions src/BladeCoverageConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@

final readonly class BladeCoverageConfig
{
/**
* Default include patterns. Covers both the standard application view
* directory and the package-per-directory monorepo layout so the plugin is
* useful with zero configuration on either project shape.
*
* @var list<string>
*/
public const array DEFAULT_INCLUDE = [
'resources/views/**/*.blade.php',
'packages/*/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 +35,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 +47,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;
}
}
44 changes: 32 additions & 12 deletions src/BladeCoveragePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,17 @@ public function addOutput(int $exitCode): int
: $this->recorder->covered();

$targets = $this->targetFinder->find($config);
$baseline = $this->baseline->load($config->baselinePath);
$strict = $config->mode === BladeCoverageConfig::MODE_STRICT;
$baseline = $strict ? [] : $this->baseline->load($config->baselinePath);
$result = $this->evaluator->evaluate($targets, $covered, $baseline);
$baselineUpdated = false;
$output = new BladeCoverageOutput($this->output);
$output = new BladeCoverageOutput($this->output, $config->groupBy);

if ($this->updateBaseline) {
if ($this->baselineGuard->blocks($result, $this->allowEmptyBaseline)) {
$output->render($result, false, $config->baselinePath);
$output->renderError($this->baselineGuard->message());
$this->writeJsonReport($result, false, $config->baselinePath, $output);
$this->writeJsonReport($result, false, $config->baselinePath, $output, $result->failed());

return $exitCode === 0 ? 1 : $exitCode;
}
Expand All @@ -170,16 +171,35 @@ public function addOutput(int $exitCode): int

$output->render($result, $baselineUpdated, $config->baselinePath);

if (! $baselineUpdated && $this->baselineConfigDrifted($config)) {
$output->renderWarning(sprintf(
'Blade coverage config changed since the baseline was generated. Re-run with %s to refresh it.',
self::UPDATE_BASELINE_OPTION,
));
$failed = $result->failed();

if (! $baselineUpdated) {
if (! $strict && $this->baselineConfigDrifted($config)) {
$output->renderWarning(sprintf(
'Blade coverage config changed since the baseline was generated. Re-run with %s to refresh it.',
self::UPDATE_BASELINE_OPTION,
));
}

if ($config->minCoverage !== null && $result->coveragePercentage() < $config->minCoverage) {
$output->renderError(sprintf(
'Coverage %.1f%% is below the required minimum of %d%%.',
$result->coveragePercentage(),
$config->minCoverage,
));
$failed = true;
}

$reporter = new GithubActionsReporter($this->output);

if ($reporter->enabled()) {
$reporter->report($result);
}
}

$this->writeJsonReport($result, $baselineUpdated, $config->baselinePath, $output);
$this->writeJsonReport($result, $baselineUpdated, $config->baselinePath, $output, $failed);

if ($baselineUpdated || ! $result->failed()) {
if ($baselineUpdated || ! $failed) {
return $exitCode;
}

Expand Down Expand Up @@ -261,7 +281,7 @@ private function baselineConfigDrifted(BladeCoverageConfig $config): bool
return $storedHash !== null && $storedHash !== $config->fingerprint();
}

private function writeJsonReport(BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath, BladeCoverageOutput $output): void
private function writeJsonReport(BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath, BladeCoverageOutput $output, bool $failed): void
{
if ($this->jsonPath === null) {
return;
Expand All @@ -271,7 +291,7 @@ private function writeJsonReport(BladeCoverageResult $result, bool $baselineUpda
? Path::normalize($this->jsonPath)
: Path::normalize($this->config()->rootPath.'/'.$this->jsonPath);

$this->jsonReport->write($path, $result, $baselineUpdated, $baselinePath);
$this->jsonReport->write($path, $result, $baselineUpdated, $baselinePath, $failed);
$output->renderJsonReport($path);
}

Expand Down
Loading