diff --git a/README.md b/README.md index c5bb30c..2719725 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 [ - '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 @@ -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`. @@ -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 diff --git a/src/BladeCoverage.php b/src/BladeCoverage.php new file mode 100644 index 0000000..6099631 --- /dev/null +++ b/src/BladeCoverage.php @@ -0,0 +1,44 @@ + + */ + 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); + } +} diff --git a/src/BladeCoverageConfig.php b/src/BladeCoverageConfig.php index 2bb71d6..233662f 100644 --- a/src/BladeCoverageConfig.php +++ b/src/BladeCoverageConfig.php @@ -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 + */ + public const array DEFAULT_INCLUDE = [ + 'resources/views/**/*.blade.php', + ]; + + public const string MODE_BASELINE = 'baseline'; + + public const string MODE_STRICT = 'strict'; + + /** @var list */ + private const array GROUP_MODES = ['auto', 'package', 'directory', 'flat']; + /** * @param list $include * @param list $exclude @@ -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, ) {} /** @@ -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 diff --git a/src/BladeCoverageJsonReport.php b/src/BladeCoverageJsonReport.php index 80c469c..b0901b2 100644 --- a/src/BladeCoverageJsonReport.php +++ b/src/BladeCoverageJsonReport.php @@ -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)) { @@ -31,7 +32,8 @@ 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), @@ -39,7 +41,7 @@ public function write(string $path, BladeCoverageResult $result, bool $baselineU '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); } diff --git a/src/BladeCoverageOutput.php b/src/BladeCoverageOutput.php index 8676740..37d6bfb 100644 --- a/src/BladeCoverageOutput.php +++ b/src/BladeCoverageOutput.php @@ -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('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) { @@ -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) { @@ -80,27 +86,4 @@ private function renderFailures(string $label, array $targets): void $this->output->writeln(sprintf(' ... and %d more', count($targets) - $rendered)); } } - - /** - * @param array $targets - * @return array> - */ - 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; - } } diff --git a/src/BladeCoveragePlugin.php b/src/BladeCoveragePlugin.php index 8655083..2ea6fb2 100644 --- a/src/BladeCoveragePlugin.php +++ b/src/BladeCoveragePlugin.php @@ -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; } @@ -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; } @@ -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; @@ -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); } diff --git a/src/BladeCoverageResult.php b/src/BladeCoverageResult.php index d78b4b6..5f35375 100644 --- a/src/BladeCoverageResult.php +++ b/src/BladeCoverageResult.php @@ -27,4 +27,19 @@ public function failed(): bool { return $this->newUncovered !== [] || $this->changedUncovered !== []; } + + /** + * Percentage of target views that were rendered. Returns 100.0 when there + * are no targets so an empty view set never reports as under-covered. + */ + public function coveragePercentage(): float + { + $total = count($this->targets); + + if ($total === 0) { + return 100.0; + } + + return round(count($this->covered) / $total * 100, 1); + } } diff --git a/src/BladeViewGrouper.php b/src/BladeViewGrouper.php new file mode 100644 index 0000000..b94a0df --- /dev/null +++ b/src/BladeViewGrouper.php @@ -0,0 +1,69 @@ +/...` paths, otherwise the + * containing directory. Suits monorepos and standard apps alike. + * - package: package name for `packages//...`, otherwise "other". + * - directory: the directory containing the view. + * - flat: a single group. + */ +final readonly class BladeViewGrouper +{ + public function __construct( + private string $mode = 'auto', + ) {} + + /** + * @param array $targets + * @return array> + */ + public function group(array $targets): array + { + $groups = []; + + foreach (array_keys($targets) as $path) { + $group = $this->groupFor($path); + $groups[$group] ??= []; + $groups[$group][] = $path; + } + + ksort($groups); + + return $groups; + } + + public function groupFor(string $path): string + { + $segments = explode('/', $path); + + return match ($this->mode) { + 'flat' => 'views', + 'package' => $this->packageGroup($segments), + 'directory' => $this->directoryGroup($path), + default => str_starts_with($path, 'packages/') + ? $this->packageGroup($segments) + : $this->directoryGroup($path), + }; + } + + /** + * @param list $segments + */ + private function packageGroup(array $segments): string + { + return count($segments) >= 2 && $segments[0] === 'packages' ? $segments[1] : 'other'; + } + + private function directoryGroup(string $path): string + { + $directory = trim(dirname($path), '.'); + + return $directory === '' ? 'views' : $directory; + } +} diff --git a/src/BladeViewTargetFinder.php b/src/BladeViewTargetFinder.php index 8b6dc14..7047731 100644 --- a/src/BladeViewTargetFinder.php +++ b/src/BladeViewTargetFinder.php @@ -10,6 +10,12 @@ final readonly class BladeViewTargetFinder { + /** + * Views containing this marker (typically inside a Blade comment such as + * `{{-- blade-coverage:ignore --}}`) are excluded from coverage targets. + */ + public const string IGNORE_MARKER = 'blade-coverage:ignore'; + public function __construct( private GlobMatcher $matcher = new GlobMatcher, ) {} @@ -47,13 +53,13 @@ public function find(BladeCoverageConfig $config): array continue; } - $hash = hash_file('sha256', $absolutePath); + $contents = file_get_contents($absolutePath); - if (! is_string($hash)) { + if ($contents === false || str_contains($contents, self::IGNORE_MARKER)) { continue; } - $targets[$relativePath] = new BladeViewTarget($relativePath, $hash); + $targets[$relativePath] = new BladeViewTarget($relativePath, hash('sha256', $contents)); } } diff --git a/src/GithubActionsReporter.php b/src/GithubActionsReporter.php new file mode 100644 index 0000000..0416e7d --- /dev/null +++ b/src/GithubActionsReporter.php @@ -0,0 +1,67 @@ +annotate($result->newUncovered, 'New uncovered Blade view: no test renders it.'); + $this->annotate($result->changedUncovered, 'Changed uncovered Blade view: contents changed without render coverage.'); + $this->writeSummary($result); + } + + /** + * @param array $targets + */ + private function annotate(array $targets, string $message): void + { + foreach (array_keys($targets) as $path) { + $this->output->writeln(sprintf('::error file=%s,line=1::%s', $path, $message)); + } + } + + private function writeSummary(BladeCoverageResult $result): void + { + $summaryPath = getenv('GITHUB_STEP_SUMMARY'); + + if (! is_string($summaryPath) || $summaryPath === '') { + return; + } + + $lines = [ + '## Blade view coverage', + '', + '| Metric | Count |', + '| --- | --- |', + sprintf('| Covered | %d (%.1f%%) |', count($result->covered), $result->coveragePercentage()), + sprintf('| Baseline-allowed | %d |', count($result->baselineAllowed)), + sprintf('| New uncovered | %d |', count($result->newUncovered)), + sprintf('| Changed uncovered | %d |', count($result->changedUncovered)), + sprintf('| Total | %d |', count($result->targets)), + '', + ]; + + file_put_contents($summaryPath, implode("\n", $lines)."\n", FILE_APPEND); + } +} diff --git a/tests/Feature/BladeRenderCollectorTest.php b/tests/Feature/BladeRenderCollectorTest.php index cd8739f..ef23d46 100644 --- a/tests/Feature/BladeRenderCollectorTest.php +++ b/tests/Feature/BladeRenderCollectorTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Capell\PestBladeCoverage\BladeCoverage; use Capell\PestBladeCoverage\BladeCoverageConfig; use Capell\PestBladeCoverage\BladeCoverageRecorder; use Capell\PestBladeCoverage\BladeViewRenderCollector; @@ -108,6 +109,24 @@ function bladeCoverageAfterResolvingViewCallbackCount(Container $container): int ]); }); +it('captures rendered views programmatically through the BladeCoverage helper', function (): void { + file_put_contents($this->bladeCoverageViews.'/programmatic.blade.php', '

Programmatic

'); + + $config = BladeCoverageConfig::fromArray([ + 'include' => ['packages/*/resources/views/**/*.blade.php'], + ], $this->bladeCoverageRoot); + + $rendered = BladeCoverage::capture(function (): void { + view('blade-coverage-test::programmatic')->render(); + }, $config); + + expect($rendered)->toBe(['packages/example/resources/views/programmatic.blade.php']) + ->and(BladeCoverage::rendered('packages/example/resources/views/programmatic.blade.php', function (): void { + view('blade-coverage-test::programmatic')->render(); + }, $config))->toBeTrue() + ->and(BladeCoverage::rendered('packages/example/resources/views/missing.blade.php', fn () => null, $config))->toBeFalse(); +}); + it('does not record blade files that are only read as source', function (): void { $path = $this->bladeCoverageViews.'/source-only.blade.php'; file_put_contents($path, '

Source only

'); diff --git a/tests/Unit/BladeCoverageVersatilityTest.php b/tests/Unit/BladeCoverageVersatilityTest.php new file mode 100644 index 0000000..3577a1f --- /dev/null +++ b/tests/Unit/BladeCoverageVersatilityTest.php @@ -0,0 +1,116 @@ + new BladeViewTarget('packages/blog/resources/views/index.blade.php', 'h'), + 'resources/views/home.blade.php' => new BladeViewTarget('resources/views/home.blade.php', 'h'), + 'resources/views/components/card.blade.php' => new BladeViewTarget('resources/views/components/card.blade.php', 'h'), + ]; + + expect(array_keys((new BladeViewGrouper('package'))->group($targets)))->toBe(['blog', 'other']) + ->and(array_keys((new BladeViewGrouper('flat'))->group($targets)))->toBe(['views']) + ->and((new BladeViewGrouper('directory'))->groupFor('resources/views/home.blade.php'))->toBe('resources/views') + ->and((new BladeViewGrouper('auto'))->groupFor('packages/blog/resources/views/index.blade.php'))->toBe('blog') + ->and((new BladeViewGrouper('auto'))->groupFor('resources/views/components/card.blade.php'))->toBe('resources/views/components'); +}); + +it('parses group_by, mode and min_coverage with safe fallbacks', function (): void { + $explicit = BladeCoverageConfig::fromArray([ + 'group_by' => 'directory', + 'mode' => 'strict', + 'min_coverage' => '150', + ], '/srv/app'); + + $defaults = BladeCoverageConfig::fromArray(['group_by' => 'nope', 'mode' => 'weird'], '/srv/app'); + + expect($explicit->groupBy)->toBe('directory') + ->and($explicit->mode)->toBe(BladeCoverageConfig::MODE_STRICT) + ->and($explicit->minCoverage)->toBe(100) + ->and($defaults->groupBy)->toBe('auto') + ->and($defaults->mode)->toBe(BladeCoverageConfig::MODE_BASELINE) + ->and($defaults->minCoverage)->toBeNull() + ->and($defaults->include)->toBe(BladeCoverageConfig::DEFAULT_INCLUDE); +}); + +it('skips blade views flagged with the ignore marker', function (): void { + $root = bladeCoverageTempRoot(); + + try { + bladeCoveragePut($root, 'packages/blog/resources/views/keep.blade.php', '

Keep

'); + bladeCoveragePut($root, 'packages/blog/resources/views/skip.blade.php', '{{-- blade-coverage:ignore --}}

Skip

'); + + $config = BladeCoverageConfig::fromArray(['include' => ['packages/*/resources/views/**/*.blade.php']], $root); + + expect(array_keys((new BladeViewTargetFinder)->find($config))) + ->toBe(['packages/blog/resources/views/keep.blade.php']); + } finally { + bladeCoverageDeleteDirectory($root); + } +}); + +it('reports coverage percentage and honours an explicit failed flag in the json report', function (): void { + $root = bladeCoverageTempRoot(); + + try { + $path = $root.'/coverage/blade.json'; + $targets = [ + 'a.blade.php' => new BladeViewTarget('a.blade.php', 'h'), + 'b.blade.php' => new BladeViewTarget('b.blade.php', 'h'), + ]; + $result = (new BladeCoverageEvaluator)->evaluate($targets, ['a.blade.php'], ['b.blade.php' => 'h']); + + expect($result->failed())->toBeFalse() + ->and($result->coveragePercentage())->toBe(50.0); + + (new BladeCoverageJsonReport)->write($path, $result, baselineUpdated: false, baselinePath: $root.'/baseline.json', failed: true); + + $decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + + expect($decoded['summary']['coveragePercentage'])->toBe(50.0) + ->and($decoded['summary']['failed'])->toBeTrue(); + } finally { + bladeCoverageDeleteDirectory($root); + } +}); + +it('emits github annotations and a step summary when running in github actions', function (): void { + $root = bladeCoverageTempRoot(); + + try { + $summaryPath = $root.'/summary.md'; + putenv('GITHUB_ACTIONS=true'); + putenv('GITHUB_STEP_SUMMARY='.$summaryPath); + + $targets = [ + 'packages/blog/resources/views/new.blade.php' => new BladeViewTarget('packages/blog/resources/views/new.blade.php', 'h'), + 'packages/blog/resources/views/ok.blade.php' => new BladeViewTarget('packages/blog/resources/views/ok.blade.php', 'h'), + ]; + $result = (new BladeCoverageEvaluator)->evaluate($targets, ['packages/blog/resources/views/ok.blade.php'], []); + + $output = new BufferedOutput; + $reporter = new GithubActionsReporter($output); + + expect($reporter->enabled())->toBeTrue(); + + $reporter->report($result); + + expect($output->fetch())->toContain('::error file=packages/blog/resources/views/new.blade.php,line=1::') + ->and(file_get_contents($summaryPath))->toContain('## Blade view coverage') + ->and(file_get_contents($summaryPath))->toContain('| Total | 2 |'); + } finally { + putenv('GITHUB_ACTIONS'); + putenv('GITHUB_STEP_SUMMARY'); + bladeCoverageDeleteDirectory($root); + } +});