mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
4 Commits
b944ee5841
...
466259646d
| Author | SHA1 | Date | |
|---|---|---|---|
| 466259646d | |||
| 00f8d56083 | |||
| ca2dca592d | |||
| 405d8d4406 |
@ -8,6 +8,7 @@ use Closure;
|
|||||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
|
use Pest\Plugins\Tia\AutoloadEdges;
|
||||||
use Pest\Plugins\Tia\BladeEdges;
|
use Pest\Plugins\Tia\BladeEdges;
|
||||||
use Pest\Plugins\Tia\InertiaEdges;
|
use Pest\Plugins\Tia\InertiaEdges;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
@ -317,6 +318,16 @@ trait Testable
|
|||||||
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$recorder = Container::getInstance()->get(Recorder::class);
|
||||||
|
|
||||||
|
if ($recorder instanceof Recorder && $recorder->isActive()) {
|
||||||
|
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
|
||||||
|
? AutoloadEdges::snapshot()
|
||||||
|
: [];
|
||||||
|
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
// TIA blade-edge + table-edge recording (Laravel-only). Runs
|
// TIA blade-edge + table-edge recording (Laravel-only). Runs
|
||||||
@ -325,7 +336,6 @@ trait Testable
|
|||||||
// idempotent against the current app instance so the 774-test
|
// idempotent against the current app instance so the 774-test
|
||||||
// suite doesn't stack 774 composers / listeners when Laravel
|
// suite doesn't stack 774 composers / listeners when Laravel
|
||||||
// keeps the same app across tests.
|
// keeps the same app across tests.
|
||||||
$recorder = Container::getInstance()->get(Recorder::class);
|
|
||||||
if ($recorder instanceof Recorder) {
|
if ($recorder instanceof Recorder) {
|
||||||
BladeEdges::arm($recorder);
|
BladeEdges::arm($recorder);
|
||||||
TableTracker::arm($recorder);
|
TableTracker::arm($recorder);
|
||||||
@ -339,6 +349,18 @@ trait Testable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->__callClosure($beforeEach, $arguments);
|
$this->__callClosure($beforeEach, $arguments);
|
||||||
|
|
||||||
|
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
||||||
|
$recorder->linkSourcesForTest(
|
||||||
|
self::$__filename,
|
||||||
|
AutoloadEdges::newProjectFiles(
|
||||||
|
$autoloadBeforeSetUp,
|
||||||
|
AutoloadEdges::snapshot(),
|
||||||
|
TestSuite::getInstance()->rootPath,
|
||||||
|
self::$__filename,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -412,6 +434,10 @@ trait Testable
|
|||||||
$tia = Container::getInstance()->get(Tia::class);
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
|
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
|
||||||
|
|
||||||
|
if ($assertions === 0) {
|
||||||
|
$this->expectNotToPerformAssertions();
|
||||||
|
}
|
||||||
|
|
||||||
$this->addToAssertionCount($assertions);
|
$this->addToAssertionCount($assertions);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -241,6 +241,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private bool $forceRefetch = false;
|
private bool $forceRefetch = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once structural-drift recovery has already tried the remote
|
||||||
|
* baseline during this process. Prevents the later "no local graph" path
|
||||||
|
* from fetching the same stale baseline again and printing duplicate drift
|
||||||
|
* / rebuild messages.
|
||||||
|
*/
|
||||||
|
private bool $baselineFetchAttemptedForDrift = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `--fresh` is in the current argv — record-mode paths
|
* True when `--fresh` is in the current argv — record-mode paths
|
||||||
* use it to gate `Graph::pruneMissingTests()`. On a partial record
|
* use it to gate `Graph::pruneMissingTests()`. On a partial record
|
||||||
@ -829,7 +837,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
// to pull a team-shared baseline so fresh checkouts (new devs, CI
|
||||||
// containers) don't pay the full record cost. If the pull succeeds
|
// containers) don't pay the full record cost. If the pull succeeds
|
||||||
// the graph is re-read and reconciled against the local env.
|
// the graph is re-read and reconciled against the local env.
|
||||||
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
if (! $graph instanceof Graph
|
||||||
|
&& ! $forceRebuild
|
||||||
|
&& ! $this->baselineFetchAttemptedForDrift
|
||||||
|
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
||||||
$graph = $this->loadGraph($projectRoot);
|
$graph = $this->loadGraph($projectRoot);
|
||||||
if ($graph instanceof Graph) {
|
if ($graph instanceof Graph) {
|
||||||
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||||
@ -1506,8 +1517,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private function tryRemoteBaselineForDrift(array $current): ?Graph
|
private function tryRemoteBaselineForDrift(array $current): ?Graph
|
||||||
{
|
{
|
||||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
|
$this->baselineFetchAttemptedForDrift = true;
|
||||||
|
|
||||||
if (! $this->baselineSync->fetchIfAvailable($projectRoot, false)) {
|
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
src/Plugins/Tia/AutoloadEdges.php
Normal file
96
src/Plugins/Tia/AutoloadEdges.php
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures PHP files that were included while a test was running.
|
||||||
|
*
|
||||||
|
* Coverage drivers miss declaration-only files (classes, enums, interfaces,
|
||||||
|
* traits) and files loaded before the coverage window opens. Diffing
|
||||||
|
* `get_included_files()` gives TIA an explicit edge for those autoloaded files.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class AutoloadEdges
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
public static function snapshot(): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach (get_included_files() as $file) {
|
||||||
|
if (is_string($file) && $file !== '') {
|
||||||
|
$files[$file] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $before
|
||||||
|
* @param array<string, true> $after
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
|
||||||
|
{
|
||||||
|
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||||
|
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach (array_keys($after) as $file) {
|
||||||
|
if (isset($before[$file])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$real = @realpath($file);
|
||||||
|
if ($real === false) {
|
||||||
|
$real = $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testReal !== false && $real === $testReal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with($real, $root)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||||
|
|
||||||
|
if (self::ignored($relative)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_ends_with($relative, '.php')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$real] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ignored(string $relative): bool
|
||||||
|
{
|
||||||
|
static $prefixes = [
|
||||||
|
'vendor/',
|
||||||
|
'node_modules/',
|
||||||
|
'storage/framework/',
|
||||||
|
'bootstrap/cache/',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($prefixes as $prefix) {
|
||||||
|
if (str_starts_with($relative, $prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -387,13 +387,30 @@ final class Graph
|
|||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
if (isset($this->fileIds[$rel])) {
|
if (isset($this->fileIds[$rel])) {
|
||||||
$changedIds[$this->fileIds[$rel]] = true;
|
$changedIds[$this->fileIds[$rel]] = true;
|
||||||
} elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
||||||
|
$absolute = $this->projectRoot.'/'.$rel;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
// Deleted source file unknown to the graph — can't affect
|
||||||
|
// any test because no edge ever pointed to it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Source PHP file unknown to the graph — might be a new file
|
// Source PHP file unknown to the graph — might be a new file
|
||||||
// that only exists on this branch (graph inherited from main).
|
// that only exists on this branch (graph inherited from main).
|
||||||
// Track its directory for the sibling heuristic (step 3).
|
// Only use the sibling heuristic for files that commonly
|
||||||
|
// participate in framework discovery / bootstrap. Ordinary new
|
||||||
|
// classes, enums, DTOs, services, etc. should not re-run sibling
|
||||||
|
// tests just because they live in the same directory.
|
||||||
|
if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
|
||||||
$unknownSourceDirs[dirname($rel)] = true;
|
$unknownSourceDirs[dirname($rel)] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($this->edges as $testFile => $ids) {
|
foreach ($this->edges as $testFile => $ids) {
|
||||||
if (isset($affectedSet[$testFile])) {
|
if (isset($affectedSet[$testFile])) {
|
||||||
@ -409,6 +426,39 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unknown Blade files can still be routed precisely when another
|
||||||
|
// recorded Blade view statically references them (`@include`,
|
||||||
|
// `@extends`, `<x-alert />`, etc.). Walk the source-level Blade graph
|
||||||
|
// upward to rendered ancestors and invalidate tests that rendered those
|
||||||
|
// ancestors instead of broadcasting every Blade edit to the whole suite.
|
||||||
|
$staticallyHandledBlade = [];
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($this->fileIds[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $this->isBladePath($rel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||||
|
|
||||||
|
if ($bladeAffected !== []) {
|
||||||
|
foreach ($bladeAffected as $testFile) {
|
||||||
|
$affectedSet[$testFile] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$staticallyHandledBlade[$rel] = true;
|
||||||
|
} elseif ($this->isBladeComponentPath($rel)) {
|
||||||
|
// Anonymous Blade components are leaf templates. If nothing in
|
||||||
|
// the project statically renders the component, treat it like an
|
||||||
|
// orphan rather than running the full suite.
|
||||||
|
$staticallyHandledBlade[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Watch-pattern lookup — fallback for files we don't have
|
// 2. Watch-pattern lookup — fallback for files we don't have
|
||||||
// precise edges for. When a file is already in `$fileIds` step
|
// precise edges for. When a file is already in `$fileIds` step
|
||||||
// 1 resolved it surgically; broadcasting it again through the
|
// 1 resolved it surgically; broadcasting it again through the
|
||||||
@ -433,7 +483,16 @@ final class Graph
|
|||||||
if (isset($sharedFilesResolved[$rel])) {
|
if (isset($sharedFilesResolved[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isset($staticallyHandledBlade[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (! isset($this->fileIds[$rel])) {
|
if (! isset($this->fileIds[$rel])) {
|
||||||
|
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||||
|
// Deleted file unknown to the graph — no edge ever
|
||||||
|
// pointed to it, so it can't affect any test.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$unknownToGraph[] = $rel;
|
$unknownToGraph[] = $rel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -455,10 +514,11 @@ final class Graph
|
|||||||
// whose graph was inherited from another branch (e.g. main). In the
|
// whose graph was inherited from another branch (e.g. main). In the
|
||||||
// latter case the graph simply never saw the file.
|
// latter case the graph simply never saw the file.
|
||||||
//
|
//
|
||||||
// To avoid silent misses: find tests that already cover ANY file in
|
// To avoid silent misses for framework-discovered files: find tests
|
||||||
// the same directory. If `app/Models/OrderItem.php` is unknown but
|
// that already cover ANY file in the same directory. If
|
||||||
// `app/Models/Order.php` is covered by `OrderTest`, run `OrderTest`
|
// `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
|
||||||
// — it likely exercises sibling files in the same module.
|
// listeners are covered by a mail-flow test, run that test — it likely
|
||||||
|
// exercises the same discovery surface.
|
||||||
//
|
//
|
||||||
// This over-runs slightly (sibling may be unrelated) but never
|
// This over-runs slightly (sibling may be unrelated) but never
|
||||||
// under-runs. And once the test executes, its coverage captures the
|
// under-runs. And once the test executes, its coverage captures the
|
||||||
@ -801,6 +861,216 @@ final class Graph
|
|||||||
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
|
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown PHP files have no historical edge yet. Keep sibling fan-out only
|
||||||
|
* for framework-discovered / boot-loaded conventions where adding a file can
|
||||||
|
* change behaviour without another source file changing too.
|
||||||
|
*/
|
||||||
|
private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
|
||||||
|
{
|
||||||
|
static $prefixes = [
|
||||||
|
'app/Providers/',
|
||||||
|
'app/Listeners/',
|
||||||
|
'app/Events/',
|
||||||
|
'app/Observers/',
|
||||||
|
'app/Policies/',
|
||||||
|
'app/Console/Commands/',
|
||||||
|
'app/Mail/',
|
||||||
|
'app/Notifications/',
|
||||||
|
'database/factories/',
|
||||||
|
'database/seeders/',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($prefixes as $prefix) {
|
||||||
|
if (str_starts_with($rel, $prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBladePath(string $rel): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($rel, 'resources/views/') && str_ends_with($rel, '.blade.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isBladeComponentPath(string $rel): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($rel, 'resources/views/components/') && str_ends_with($rel, '.blade.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Project-relative test files.
|
||||||
|
*/
|
||||||
|
private function affectedByStaticBladeUsage(string $changedBlade): array
|
||||||
|
{
|
||||||
|
$ancestors = $this->bladeAncestorsFor($changedBlade);
|
||||||
|
|
||||||
|
if ($ancestors === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ancestorIds = [];
|
||||||
|
foreach ($ancestors as $ancestor) {
|
||||||
|
if (isset($this->fileIds[$ancestor])) {
|
||||||
|
$ancestorIds[$this->fileIds[$ancestor]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ancestorIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$affected = [];
|
||||||
|
foreach ($this->edges as $testFile => $ids) {
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (isset($ancestorIds[$id])) {
|
||||||
|
$affected[$testFile] = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($affected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Project-relative Blade files that statically depend on $changedBlade, directly or transitively.
|
||||||
|
*/
|
||||||
|
private function bladeAncestorsFor(string $changedBlade): array
|
||||||
|
{
|
||||||
|
$allBladeFiles = $this->allBladeFiles();
|
||||||
|
|
||||||
|
if ($allBladeFiles === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$targets = [$changedBlade => true];
|
||||||
|
$ancestors = [];
|
||||||
|
$changed = true;
|
||||||
|
|
||||||
|
while ($changed) {
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
foreach ($allBladeFiles as $candidate) {
|
||||||
|
if (isset($targets[$candidate])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($ancestors[$candidate])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = @file_get_contents($this->projectRoot.'/'.$candidate);
|
||||||
|
if ($source === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($targets) as $target) {
|
||||||
|
if ($this->bladeSourceReferences($source, $target)) {
|
||||||
|
$ancestors[$candidate] = true;
|
||||||
|
$targets[$candidate] = true;
|
||||||
|
$changed = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($ancestors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function allBladeFiles(): array
|
||||||
|
{
|
||||||
|
$views = $this->projectRoot.'/resources/views';
|
||||||
|
|
||||||
|
if (! is_dir($views)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($views, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file instanceof \SplFileInfo || ! $file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $file->getPathname();
|
||||||
|
if (! str_ends_with($path, '.blade.php')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->projectRoot) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bladeSourceReferences(string $source, string $targetBlade): bool
|
||||||
|
{
|
||||||
|
$view = $this->viewNameForBlade($targetBlade);
|
||||||
|
|
||||||
|
if ($view !== null) {
|
||||||
|
$quoted = preg_quote($view, '#');
|
||||||
|
|
||||||
|
if (preg_match('#@(include|includeIf|includeWhen|includeUnless|extends|component|each)\s*\([^)]*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#\b(view|View::make)\s*\(\s*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->componentNamesForBlade($targetBlade) as $component) {
|
||||||
|
$quoted = preg_quote($component, '#');
|
||||||
|
|
||||||
|
if (preg_match('#<x-'.$quoted.'(?=[\s>/.:])#i', $source) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function viewNameForBlade(string $rel): ?string
|
||||||
|
{
|
||||||
|
if (! $this->isBladePath($rel)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tail = substr($rel, strlen('resources/views/'));
|
||||||
|
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||||
|
|
||||||
|
return str_replace('/', '.', $tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function componentNamesForBlade(string $rel): array
|
||||||
|
{
|
||||||
|
if (! $this->isBladeComponentPath($rel)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tail = substr($rel, strlen('resources/views/components/'));
|
||||||
|
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||||
|
$name = str_replace('/', '.', $tail);
|
||||||
|
|
||||||
|
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads `$rel` relative to the project root and extracts the
|
* Reads `$rel` relative to the project root and extracts the
|
||||||
* tables it declares via `Schema::create/table/drop/rename`.
|
* tables it declares via `Schema::create/table/drop/rename`.
|
||||||
|
|||||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\TestSuite;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -108,6 +109,20 @@ final class Recorder
|
|||||||
*/
|
*/
|
||||||
private array $classDependencyCache = [];
|
private array $classDependencyCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached test-file import resolution.
|
||||||
|
*
|
||||||
|
* @var array<string, list<string>>
|
||||||
|
*/
|
||||||
|
private array $testImportFileCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Included-file snapshot captured at the start of the current test.
|
||||||
|
*
|
||||||
|
* @var array<string, true>
|
||||||
|
*/
|
||||||
|
private array $includedFilesAtTestStart = [];
|
||||||
|
|
||||||
private bool $active = false;
|
private bool $active = false;
|
||||||
|
|
||||||
private bool $driverChecked = false;
|
private bool $driverChecked = false;
|
||||||
@ -169,6 +184,10 @@ final class Recorder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->currentTestFile !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$file = $this->resolveTestFile($className, $fallbackFile);
|
$file = $this->resolveTestFile($className, $fallbackFile);
|
||||||
|
|
||||||
if ($file === null) {
|
if ($file === null) {
|
||||||
@ -176,6 +195,7 @@ final class Recorder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->currentTestFile = $file;
|
$this->currentTestFile = $file;
|
||||||
|
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
|
||||||
|
|
||||||
if ($this->classUsesDatabase($className)) {
|
if ($this->classUsesDatabase($className)) {
|
||||||
$this->perTestUsesDatabase[$file] = true;
|
$this->perTestUsesDatabase[$file] = true;
|
||||||
@ -193,6 +213,7 @@ final class Recorder
|
|||||||
// the explicit walk for ancestors whose own bodies might be
|
// the explicit walk for ancestors whose own bodies might be
|
||||||
// empty.
|
// empty.
|
||||||
$this->linkAncestorFiles($className);
|
$this->linkAncestorFiles($className);
|
||||||
|
$this->linkImportedFiles($file);
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
@ -228,6 +249,15 @@ final class Recorder
|
|||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (AutoloadEdges::newProjectFiles(
|
||||||
|
$this->includedFilesAtTestStart,
|
||||||
|
AutoloadEdges::snapshot(),
|
||||||
|
TestSuite::getInstance()->rootPath,
|
||||||
|
$this->currentTestFile,
|
||||||
|
) as $sourceFile) {
|
||||||
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Walk each covered class's interfaces / traits / parent chain
|
// Walk each covered class's interfaces / traits / parent chain
|
||||||
// and link those files explicitly. Interface declarations have
|
// and link those files explicitly. Interface declarations have
|
||||||
// no executable bytecode, so coverage drivers never emit lines
|
// no executable bytecode, so coverage drivers never emit lines
|
||||||
@ -239,6 +269,7 @@ final class Recorder
|
|||||||
$this->linkSourceDependencies(array_keys($data));
|
$this->linkSourceDependencies(array_keys($data));
|
||||||
|
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
|
$this->includedFilesAtTestStart = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -267,6 +298,31 @@ final class Recorder
|
|||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records source dependencies for a specific test file. Used for edges
|
||||||
|
* captured before `Prepared` has opened the normal per-test recorder window.
|
||||||
|
*
|
||||||
|
* @param iterable<int, string> $sourceFiles
|
||||||
|
*/
|
||||||
|
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
|
||||||
|
{
|
||||||
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testFile === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($sourceFiles as $sourceFile) {
|
||||||
|
if ($sourceFile === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->perTestFiles[$testFile][$sourceFile] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For each project-local source file the coverage driver
|
* For each project-local source file the coverage driver
|
||||||
* captured for this test, finds the classes / interfaces / traits
|
* captured for this test, finds the classes / interfaces / traits
|
||||||
@ -468,6 +524,135 @@ final class Recorder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links project-local classes imported by the test file. This catches
|
||||||
|
* declaration-only support classes / enums / interfaces that may never emit
|
||||||
|
* executable coverage lines, and avoids relying on global autoload timing.
|
||||||
|
*/
|
||||||
|
private function linkImportedFiles(string $testFile): void
|
||||||
|
{
|
||||||
|
if ($this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->importedFilesFor($testFile) as $file) {
|
||||||
|
$this->perTestFiles[$this->currentTestFile][$file] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function importedFilesFor(string $testFile): array
|
||||||
|
{
|
||||||
|
if (array_key_exists($testFile, $this->testImportFileCache)) {
|
||||||
|
return $this->testImportFileCache[$testFile];
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = @file_get_contents($testFile);
|
||||||
|
if ($source === false) {
|
||||||
|
return $this->testImportFileCache[$testFile] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach ($this->importedClassNames($source) as $className) {
|
||||||
|
$file = $this->findAutoloadFile($className);
|
||||||
|
|
||||||
|
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||||
|
$files[$file] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->testImportFileCache[$testFile] = array_keys($files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function importedClassNames(string $source): array
|
||||||
|
{
|
||||||
|
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
|
||||||
|
|
||||||
|
$classes = [];
|
||||||
|
|
||||||
|
foreach ($matches[1] as $import) {
|
||||||
|
$import = trim($import);
|
||||||
|
|
||||||
|
if ($import === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$open = strpos($import, '{');
|
||||||
|
$close = strrpos($import, '}');
|
||||||
|
|
||||||
|
if ($open !== false && $close !== false && $close > $open) {
|
||||||
|
$prefix = trim(trim(substr($import, 0, $open)), '\\');
|
||||||
|
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
|
||||||
|
|
||||||
|
if ($class !== null) {
|
||||||
|
$classes[$class] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = $this->normaliseImportedClass($import);
|
||||||
|
|
||||||
|
if ($class !== null) {
|
||||||
|
$classes[$class] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normaliseImportedClass(string $import): ?string
|
||||||
|
{
|
||||||
|
$import = trim(trim($import), '\\');
|
||||||
|
|
||||||
|
if ($import === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split('/\s+as\s+/i', $import);
|
||||||
|
if ($parts === false || $parts === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = trim(trim($parts[0]), '\\');
|
||||||
|
|
||||||
|
return $class === '' ? null : $class;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAutoloadFile(string $className): ?string
|
||||||
|
{
|
||||||
|
foreach (spl_autoload_functions() as $loader) {
|
||||||
|
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! method_exists($loader[0], 'findFile')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var mixed $file */
|
||||||
|
$file = $loader[0]->findFile($className);
|
||||||
|
|
||||||
|
if (is_string($file) && $file !== '') {
|
||||||
|
$real = @realpath($file);
|
||||||
|
|
||||||
|
return $real === false ? $file : $real;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `$className` (or any of its ancestors) uses one of
|
* True when `$className` (or any of its ancestors) uses one of
|
||||||
* Laravel's database-resetting traits. Walking up `getTraits()` is
|
* Laravel's database-resetting traits. Walking up `getTraits()` is
|
||||||
|
|||||||
@ -12,9 +12,8 @@ use Pest\TestSuite;
|
|||||||
/**
|
/**
|
||||||
* Watch patterns for frontend assets that affect browser tests.
|
* Watch patterns for frontend assets that affect browser tests.
|
||||||
*
|
*
|
||||||
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to
|
* Uses `BrowserTestIdentifier` from pest-plugin-browser to auto-discover tests
|
||||||
* auto-discover directories containing browser tests. Falls back to the
|
* using `visit()`. Also keeps the `tests/Browser` convention when present.
|
||||||
* `tests/Browser` convention when the plugin is absent.
|
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@ -31,7 +30,7 @@ final readonly class Browser implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath);
|
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
|
||||||
|
|
||||||
$globs = [
|
$globs = [
|
||||||
'resources/js/**/*.js',
|
'resources/js/**/*.js',
|
||||||
@ -51,7 +50,7 @@ final readonly class Browser implements WatchDefault
|
|||||||
$patterns = [];
|
$patterns = [];
|
||||||
|
|
||||||
foreach ($globs as $glob) {
|
foreach ($globs as $glob) {
|
||||||
$patterns[$glob] = $browserDirs;
|
$patterns[$glob] = $browserTargets;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $patterns;
|
return $patterns;
|
||||||
@ -60,19 +59,19 @@ final readonly class Browser implements WatchDefault
|
|||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private function detectBrowserTestDirs(string $projectRoot, string $testPath): array
|
public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$dirs = [];
|
$targets = [];
|
||||||
|
|
||||||
$candidate = $testPath.'/Browser';
|
$candidate = $testPath.'/Browser';
|
||||||
|
|
||||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
||||||
$dirs[] = $candidate;
|
$targets[] = $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
||||||
// is installed to find tests using `visit()` outside the conventional
|
// is installed to find exact tests using `visit()` outside the
|
||||||
// Browser/ folder.
|
// conventional Browser/ folder.
|
||||||
if (class_exists(BrowserTestIdentifier::class)) {
|
if (class_exists(BrowserTestIdentifier::class)) {
|
||||||
$repo = TestSuite::getInstance()->tests;
|
$repo = TestSuite::getInstance()->tests;
|
||||||
|
|
||||||
@ -85,10 +84,10 @@ final readonly class Browser implements WatchDefault
|
|||||||
|
|
||||||
foreach ($factory->methods as $method) {
|
foreach ($factory->methods as $method) {
|
||||||
if (BrowserTestIdentifier::isBrowserTest($method)) {
|
if (BrowserTestIdentifier::isBrowserTest($method)) {
|
||||||
$rel = $this->fileRelative($projectRoot, $filename);
|
$rel = self::fileRelative($projectRoot, $filename);
|
||||||
|
|
||||||
if ($rel !== null) {
|
if ($rel !== null) {
|
||||||
$dirs[] = dirname($rel);
|
$targets[] = $rel;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@ -97,10 +96,10 @@ final readonly class Browser implements WatchDefault
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($dirs === [] ? [$testPath] : $dirs));
|
return array_values(array_unique($targets));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function fileRelative(string $projectRoot, string $path): ?string
|
private static function fileRelative(string $projectRoot, string $path): ?string
|
||||||
{
|
{
|
||||||
$real = @realpath($path);
|
$real = @realpath($path);
|
||||||
|
|
||||||
|
|||||||
@ -26,12 +26,10 @@ final readonly class Inertia implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser')
|
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
|
||||||
? $testPath.'/Browser'
|
|
||||||
: $testPath;
|
|
||||||
|
|
||||||
// Inertia page components (React / Vue / Svelte). Scoped to
|
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||||
// `$browserDir` only — a Vue/React edit cannot change the
|
// browser tests only — a Vue/React edit cannot change the
|
||||||
// output of a server-side Inertia test (those assert on the
|
// output of a server-side Inertia test (those assert on the
|
||||||
// component *name* returned by `Inertia::render()`, not its
|
// component *name* returned by `Inertia::render()`, not its
|
||||||
// client-side implementation). Broad invalidation is only
|
// client-side implementation). Broad invalidation is only
|
||||||
@ -47,21 +45,21 @@ final readonly class Inertia implements WatchDefault
|
|||||||
|
|
||||||
foreach (['Pages', 'pages'] as $pages) {
|
foreach (['Pages', 'pages'] as $pages) {
|
||||||
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
|
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
|
||||||
$patterns["resources/js/{$pages}/**/*.{$ext}"] = [$browserDir];
|
$patterns["resources/js/{$pages}/**/*.{$ext}"] = $browserTargets;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
|
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
|
||||||
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
|
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
|
||||||
$patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir];
|
$patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSR entry point.
|
// SSR entry point.
|
||||||
$patterns['resources/js/ssr.js'] = [$browserDir];
|
$patterns['resources/js/ssr.js'] = $browserTargets;
|
||||||
$patterns['resources/js/ssr.ts'] = [$browserDir];
|
$patterns['resources/js/ssr.ts'] = $browserTargets;
|
||||||
$patterns['resources/js/app.js'] = [$browserDir];
|
$patterns['resources/js/app.js'] = $browserTargets;
|
||||||
$patterns['resources/js/app.ts'] = [$browserDir];
|
$patterns['resources/js/app.ts'] = $browserTargets;
|
||||||
|
|
||||||
return $patterns;
|
return $patterns;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,10 +27,6 @@ final readonly class Laravel implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$featurePath = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Feature')
|
|
||||||
? $testPath.'/Feature'
|
|
||||||
: $testPath;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Config — loaded during app boot (setUp), invisible to coverage.
|
// Config — loaded during app boot (setUp), invisible to coverage.
|
||||||
// Affects both Feature and Unit: Pest.php commonly binds fakes
|
// Affects both Feature and Unit: Pest.php commonly binds fakes
|
||||||
@ -39,8 +35,8 @@ final readonly class Laravel implements WatchDefault
|
|||||||
'config/**/*.php' => [$testPath],
|
'config/**/*.php' => [$testPath],
|
||||||
|
|
||||||
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
||||||
'routes/*.php' => [$featurePath],
|
'routes/*.php' => [$testPath],
|
||||||
'routes/**/*.php' => [$featurePath],
|
'routes/**/*.php' => [$testPath],
|
||||||
|
|
||||||
// Service providers / bootstrap — loaded during boot, affect
|
// Service providers / bootstrap — loaded during boot, affect
|
||||||
// bindings, middleware, event listeners, scheduled tasks.
|
// bindings, middleware, event listeners, scheduled tasks.
|
||||||
@ -59,27 +55,27 @@ final readonly class Laravel implements WatchDefault
|
|||||||
'database/factories/**/*.php' => [$testPath],
|
'database/factories/**/*.php' => [$testPath],
|
||||||
|
|
||||||
// Blade templates — compiled to cache, source file not executed.
|
// Blade templates — compiled to cache, source file not executed.
|
||||||
'resources/views/**/*.blade.php' => [$featurePath],
|
'resources/views/**/*.blade.php' => [$testPath],
|
||||||
// Email templates are nested under views/email or views/emails
|
// Email templates are nested under views/email or views/emails
|
||||||
// by convention and power mailable tests that render markup.
|
// by convention and power mailable tests that render markup.
|
||||||
'resources/views/email/**/*.blade.php' => [$featurePath],
|
'resources/views/email/**/*.blade.php' => [$testPath],
|
||||||
'resources/views/emails/**/*.blade.php' => [$featurePath],
|
'resources/views/emails/**/*.blade.php' => [$testPath],
|
||||||
|
|
||||||
// Translations — JSON translations read via file_get_contents,
|
// Translations — JSON translations read via file_get_contents,
|
||||||
// PHP translations loaded via include (but during boot).
|
// PHP translations loaded via include (but during boot).
|
||||||
'lang/**/*.php' => [$featurePath],
|
'lang/**/*.php' => [$testPath],
|
||||||
'lang/**/*.json' => [$featurePath],
|
'lang/**/*.json' => [$testPath],
|
||||||
'resources/lang/**/*.php' => [$featurePath],
|
'resources/lang/**/*.php' => [$testPath],
|
||||||
'resources/lang/**/*.json' => [$featurePath],
|
'resources/lang/**/*.json' => [$testPath],
|
||||||
|
|
||||||
// Build tool config — affects compiled assets consumed by
|
// Build tool config — affects compiled assets consumed by
|
||||||
// browser and Inertia tests.
|
// browser and Inertia tests.
|
||||||
'vite.config.js' => [$featurePath],
|
'vite.config.js' => [$testPath],
|
||||||
'vite.config.ts' => [$featurePath],
|
'vite.config.ts' => [$testPath],
|
||||||
'webpack.mix.js' => [$featurePath],
|
'webpack.mix.js' => [$testPath],
|
||||||
'tailwind.config.js' => [$featurePath],
|
'tailwind.config.js' => [$testPath],
|
||||||
'tailwind.config.ts' => [$featurePath],
|
'tailwind.config.ts' => [$testPath],
|
||||||
'postcss.config.js' => [$featurePath],
|
'postcss.config.js' => [$testPath],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,13 @@ use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
|||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps non-PHP file globs to the test directories they should invalidate.
|
* Maps non-PHP file globs to the tests they should invalidate.
|
||||||
*
|
*
|
||||||
* Coverage drivers only see `.php` files. Frontend assets, config files,
|
* Coverage drivers only see `.php` files. Frontend assets, config files,
|
||||||
* Blade templates, routes and environment files are invisible to the graph.
|
* Blade templates, routes and environment files are invisible to the graph.
|
||||||
* Watch patterns bridge the gap: when a changed file matches a glob, every
|
* Watch patterns bridge the gap: when a changed file matches a glob, every
|
||||||
* test under the associated directory is marked as affected.
|
* test under the associated directory (or the exact associated test file) is
|
||||||
|
* marked as affected.
|
||||||
*
|
*
|
||||||
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
|
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
|
||||||
* each implementation probes the current project and contributes patterns
|
* each implementation probes the current project and contributes patterns
|
||||||
@ -38,7 +39,7 @@ final class WatchPatterns
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array<int, string>> glob → list of project-relative test dirs
|
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files
|
||||||
*/
|
*/
|
||||||
private array $patterns = [];
|
private array $patterns = [];
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ final class WatchPatterns
|
|||||||
* Adds user-defined patterns. Merges with existing entries so a single
|
* Adds user-defined patterns. Merges with existing entries so a single
|
||||||
* glob can map to multiple directories.
|
* glob can map to multiple directories.
|
||||||
*
|
*
|
||||||
* @param array<string, string> $patterns glob → project-relative test dir
|
* @param array<string, string> $patterns glob → project-relative test dir/file
|
||||||
*/
|
*/
|
||||||
public function add(array $patterns): void
|
public function add(array $patterns): void
|
||||||
{
|
{
|
||||||
@ -83,12 +84,12 @@ final class WatchPatterns
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all test directories whose watch patterns match at least one of
|
* Returns all test targets whose watch patterns match at least one of
|
||||||
* the given changed files.
|
* the given changed files.
|
||||||
*
|
*
|
||||||
* @param string $projectRoot Absolute path.
|
* @param string $projectRoot Absolute path.
|
||||||
* @param array<int, string> $changedFiles Project-relative paths.
|
* @param array<int, string> $changedFiles Project-relative paths.
|
||||||
* @return array<int, string> Project-relative test directories.
|
* @return array<int, string> Project-relative test dirs/files.
|
||||||
*/
|
*/
|
||||||
public function matchedDirectories(string $projectRoot, array $changedFiles): array
|
public function matchedDirectories(string $projectRoot, array $changedFiles): array
|
||||||
{
|
{
|
||||||
@ -112,10 +113,10 @@ final class WatchPatterns
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given the affected directories, returns every test file in the graph
|
* Given the affected targets, returns every test file in the graph that
|
||||||
* that lives under one of those directories.
|
* either matches an exact file target or lives under a directory target.
|
||||||
*
|
*
|
||||||
* @param array<int, string> $directories Project-relative dirs.
|
* @param array<int, string> $directories Project-relative dirs/files.
|
||||||
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@ -128,8 +129,14 @@ final class WatchPatterns
|
|||||||
$affected = [];
|
$affected = [];
|
||||||
|
|
||||||
foreach ($allTestFiles as $testFile) {
|
foreach ($allTestFiles as $testFile) {
|
||||||
foreach ($directories as $dir) {
|
foreach ($directories as $target) {
|
||||||
$prefix = rtrim($dir, '/').'/';
|
if ($testFile === $target) {
|
||||||
|
$affected[] = $testFile;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = rtrim($target, '/').'/';
|
||||||
|
|
||||||
if (str_starts_with($testFile, $prefix)) {
|
if (str_starts_with($testFile, $prefix)) {
|
||||||
$affected[] = $testFile;
|
$affected[] = $testFile;
|
||||||
|
|||||||
@ -10,8 +10,9 @@ use PHPUnit\Event\Test\Prepared;
|
|||||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts PCOV collection before each test. No-op unless the TIA recorder was
|
* Starts PCOV collection before each test. Pest tests start from
|
||||||
* activated by the `--tia` plugin.
|
* `Testable::setUp()` so Laravel boot is covered; this subscriber remains the
|
||||||
|
* fallback for PHPUnit-style tests and is idempotent for Pest tests.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user