diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ce8c24f --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cc3a41 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 09791b7..c5bb30c 100644 --- a/README.md +++ b/README.md @@ -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 { diff --git a/src/BladeCoverageBaseline.php b/src/BladeCoverageBaseline.php index f0a5f6d..1319b4e 100644 --- a/src/BladeCoverageBaseline.php +++ b/src/BladeCoverageBaseline.php @@ -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) { @@ -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 $uncoveredTargets */ @@ -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(), ]; } @@ -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|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; + } } diff --git a/src/BladeCoverageConfig.php b/src/BladeCoverageConfig.php index 67663c9..2bb71d6 100644 --- a/src/BladeCoverageConfig.php +++ b/src/BladeCoverageConfig.php @@ -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)) { @@ -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; } } diff --git a/src/BladeCoverageOutput.php b/src/BladeCoverageOutput.php index 1c65a77..8676740 100644 --- a/src/BladeCoverageOutput.php +++ b/src/BladeCoverageOutput.php @@ -40,6 +40,11 @@ public function renderError(string $message): void $this->output->writeln(sprintf(' %s', $message)); } + public function renderWarning(string $message): void + { + $this->output->writeln(sprintf(' %s', $message)); + } + public function renderJsonReport(string $path): void { $this->output->writeln(sprintf(' JSON report: %s', $path)); diff --git a/src/BladeCoveragePlugin.php b/src/BladeCoveragePlugin.php index 2d8a2e5..8655083 100644 --- a/src/BladeCoveragePlugin.php +++ b/src/BladeCoveragePlugin.php @@ -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()) { @@ -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) { diff --git a/tests/Feature/BladeRenderCollectorTest.php b/tests/Feature/BladeRenderCollectorTest.php index 5c7fb99..cd8739f 100644 --- a/tests/Feature/BladeRenderCollectorTest.php +++ b/tests/Feature/BladeRenderCollectorTest.php @@ -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'; diff --git a/tests/Unit/BladeCoverageServicesTest.php b/tests/Unit/BladeCoverageServicesTest.php index 24ffab8..819f561 100644 --- a/tests/Unit/BladeCoverageServicesTest.php +++ b/tests/Unit/BladeCoverageServicesTest.php @@ -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'),