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
56 changes: 56 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: tests

on:
push:
branches:
- main
pull_request:

jobs:
tests:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
php: ['8.3', '8.4']
dependency-version: [prefer-lowest, prefer-stable]

name: PHP ${{ matrix.php }} - ${{ matrix.dependency-version }}

steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- name: Setup PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1
with:
php-version: ${{ matrix.php }}
coverage: none

- name: Install dependencies
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --prefer-dist

- name: Run tests
run: vendor/bin/pest --colors=always

lint:
runs-on: ubuntu-latest

name: Pint

steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1

- name: Setup PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2.37.1
with:
php-version: '8.4'
coverage: none

- name: Install dependencies
run: composer update --prefer-stable --no-interaction --no-progress --prefer-dist

- name: Check code style
run: vendor/bin/pint --test
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Capell

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ view through Laravel, or by deleting the Blade file if it is genuinely unused.
## Baseline Format

The baseline stores uncovered views by normalized path and content hash. It also stores
metadata that helps catch accidental config drift:
the include/exclude fingerprint so the run can warn when the configuration has changed
since the baseline was generated (re-run with `--blade-coverage-update-baseline` to
refresh it). The warning is informational and does not change the exit code:

```json
{
Expand Down
67 changes: 46 additions & 21 deletions src/BladeCoverageBaseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,16 @@ final class BladeCoverageBaseline
*/
public function load(string $path): array
{
if (! is_file($path)) {
return [];
}
$decoded = $this->decode($path);

$contents = file_get_contents($path);

if ($contents === false || trim($contents) === '') {
if ($decoded === null) {
return [];
}

try {
$decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $jsonException) {
throw new RuntimeException(sprintf('Unable to decode Blade coverage baseline [%s]: %s', $path, $jsonException->getMessage()), previous: $jsonException);
}

$views = is_array($decoded) && isset($decoded['views']) && is_array($decoded['views'])
$views = isset($decoded['views']) && is_array($decoded['views'])
? $decoded['views']
: $decoded;

if (! is_array($views)) {
return [];
}

$baseline = [];

foreach ($views as $view => $hash) {
Expand All @@ -51,6 +37,24 @@ public function load(string $path): array
return $baseline;
}

/**
* Returns the config fingerprint stored in the baseline, if present.
*
* Used to detect when the include/exclude configuration has changed since
* the baseline was generated. Older path-to-hash baselines return null.
*/
public function loadConfigHash(string $path): ?string
{
$decoded = $this->decode($path);
$config = $decoded['config'] ?? null;

if (! is_array($config)) {
return null;
}

return isset($config['hash']) && is_string($config['hash']) ? $config['hash'] : null;
}

/**
* @param array<string, BladeViewTarget> $uncoveredTargets
*/
Expand Down Expand Up @@ -91,10 +95,7 @@ public function write(string $path, array $uncoveredTargets, ?BladeCoverageResul
$payload['config'] = [
'include' => $config->include,
'exclude' => $config->exclude,
'hash' => hash('sha256', json_encode([
'include' => $config->include,
'exclude' => $config->exclude,
], JSON_THROW_ON_ERROR)),
'hash' => $config->fingerprint(),
];
}

Expand All @@ -104,4 +105,28 @@ public function write(string $path, array $uncoveredTargets, ?BladeCoverageResul
throw new RuntimeException(sprintf('Unable to write Blade coverage baseline [%s].', $path));
}
}

/**
* @return array<mixed>|null
*/
private function decode(string $path): ?array
{
if (! is_file($path)) {
return null;
}

$contents = file_get_contents($path);

if ($contents === false || trim($contents) === '') {
return null;
}

try {
$decoded = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $jsonException) {
throw new RuntimeException(sprintf('Unable to decode Blade coverage baseline [%s]: %s', $path, $jsonException->getMessage()), previous: $jsonException);
}

return is_array($decoded) ? $decoded : null;
}
}
16 changes: 12 additions & 4 deletions src/BladeCoverageConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ public static function fromArray(array $config, string $rootPath): self
$include = self::stringList($config['include'] ?? ['packages/*/resources/views/**/*.blade.php']);
$exclude = self::stringList($config['exclude'] ?? []);

$baselinePath = self::resolvePath($rootPath, self::stringValue($config['baseline'] ?? $config['baseline_path'] ?? 'tests/BladeCoverage/baseline.json'));
$cachePath = self::resolvePath($rootPath, self::stringValue($config['cache'] ?? $config['cache_path'] ?? '.cache/pest-blade-coverage'));
$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);
}

public function fingerprint(): string
{
return hash('sha256', (string) json_encode([
'include' => $this->include,
'exclude' => $this->exclude,
], JSON_THROW_ON_ERROR));
}

private static function resolvePath(string $rootPath, string $path): string
{
if (Path::isAbsolute($path)) {
Expand All @@ -55,8 +63,8 @@ private static function stringList(mixed $value): array
return array_values(array_filter($value, is_string(...)));
}

private static function stringValue(mixed $value): string
private static function stringValue(mixed $value, string $default): string
{
return is_string($value) && $value !== '' ? $value : '';
return is_string($value) && $value !== '' ? $value : $default;
}
}
5 changes: 5 additions & 0 deletions src/BladeCoverageOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ public function renderError(string $message): void
$this->output->writeln(sprintf(' <fg=red>%s</>', $message));
}

public function renderWarning(string $message): void
{
$this->output->writeln(sprintf(' <fg=yellow>%s</>', $message));
}

public function renderJsonReport(string $path): void
{
$this->output->writeln(sprintf(' JSON report: %s', $path));
Expand Down
15 changes: 15 additions & 0 deletions src/BladeCoveragePlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ 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,
));
}

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

if ($baselineUpdated || ! $result->failed()) {
Expand Down Expand Up @@ -246,6 +254,13 @@ private function config(): BladeCoverageConfig
return $this->cachedConfig ??= $this->configLoader->load($this->configPath);
}

private function baselineConfigDrifted(BladeCoverageConfig $config): bool
{
$storedHash = $this->baseline->loadConfigHash($config->baselinePath);

return $storedHash !== null && $storedHash !== $config->fingerprint();
}

private function writeJsonReport(BladeCoverageResult $result, bool $baselineUpdated, string $baselinePath, BladeCoverageOutput $output): void
{
if ($this->jsonPath === null) {
Expand Down
6 changes: 6 additions & 0 deletions tests/Feature/BladeRenderCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@
use Capell\PestBladeCoverage\BladeViewRenderCollector;
use Illuminate\Container\Container;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Testing\Concerns\InteractsWithViews;
use Illuminate\Support\Facades\Route;
use Illuminate\View\Factory;

// Older Testbench TestCase versions do not pull in InteractsWithViews, which
// provides the $this->view() helper. Apply it explicitly so the helper exists
// regardless of the resolved Laravel/Testbench version.
uses(InteractsWithViews::class);

beforeEach(function (): void {
$this->bladeCoverageRoot = bladeCoverageTempRoot();
$this->bladeCoverageViews = $this->bladeCoverageRoot.'/packages/example/resources/views';
Expand Down
54 changes: 54 additions & 0 deletions tests/Unit/BladeCoverageServicesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,60 @@
}
});

it('falls back to default baseline and cache paths for empty or invalid values', function (): void {
$root = '/srv/app';

$empty = BladeCoverageConfig::fromArray(['baseline' => '', 'cache' => ''], $root);
$invalid = BladeCoverageConfig::fromArray(['baseline' => ['x'], 'cache' => 123], $root);
$custom = BladeCoverageConfig::fromArray(['baseline' => 'custom/baseline.json'], $root);

expect($empty->baselinePath)->toBe('/srv/app/tests/BladeCoverage/baseline.json')
->and($empty->cachePath)->toBe('/srv/app/.cache/pest-blade-coverage')
->and($invalid->baselinePath)->toBe('/srv/app/tests/BladeCoverage/baseline.json')
->and($invalid->cachePath)->toBe('/srv/app/.cache/pest-blade-coverage')
->and($custom->baselinePath)->toBe('/srv/app/custom/baseline.json');
});

it('fingerprints include and exclude config and reads it back from the baseline', function (): void {
$root = bladeCoverageTempRoot();

try {
$path = $root.'/tests/BladeCoverage/baseline.json';
$config = BladeCoverageConfig::fromArray([
'include' => ['packages/*/resources/views/**/*.blade.php'],
'exclude' => ['packages/demo/resources/views/ignored.blade.php'],
], $root);
$changedConfig = BladeCoverageConfig::fromArray([
'include' => ['packages/*/resources/views/**/*.blade.php'],
'exclude' => [],
], $root);

(new BladeCoverageBaseline)->write($path, [], null, $config);

$storedHash = (new BladeCoverageBaseline)->loadConfigHash($path);

expect($storedHash)->toBe($config->fingerprint())
->and($storedHash)->not->toBe($changedConfig->fingerprint());
} finally {
bladeCoverageDeleteDirectory($root);
}
});

it('returns no config fingerprint for legacy path-to-hash baselines', function (): void {
$root = bladeCoverageTempRoot();

try {
$path = $root.'/baseline.json';
file_put_contents($path, json_encode(['packages/blog/resources/views/index.blade.php' => 'hash']));

expect((new BladeCoverageBaseline)->loadConfigHash($path))->toBeNull()
->and(array_keys((new BladeCoverageBaseline)->load($path)))
->toBe(['packages/blog/resources/views/index.blade.php']);
} finally {
bladeCoverageDeleteDirectory($root);
}
});

it('evaluates new changed and baseline-allowed uncovered views', function (): void {
$targets = [
'covered.blade.php' => new BladeViewTarget('covered.blade.php', 'covered-hash'),
Expand Down