mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 10:52:14 +02:00
Compare commits
78 Commits
c38d32ae86
...
4.x
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d93c9c373 | |||
| e5ab3af05b | |||
| 40b88b62ef | |||
| e3361bc321 | |||
| 92e76eb5ab | |||
| bd22f478b8 | |||
| eeaac34cf6 | |||
| b9b07d8983 | |||
| 6aa7d2f891 | |||
| 1c21a7647a | |||
| d649de1988 | |||
| 783ca4bcd6 | |||
| ba07497219 | |||
| 1ca021dea6 | |||
| 2fc75cfcf0 | |||
| 6cc48f63f8 | |||
| e0419d1328 | |||
| faa6988801 | |||
| c12247fafd | |||
| 29b4452443 | |||
| 1b168aba1c | |||
| 6aabd977cd | |||
| a882543c53 | |||
| c250b9da4f | |||
| 46bc3dc628 | |||
| d3ce498b8a | |||
| e1a4b98b71 | |||
| 9afbcd5c18 | |||
| 75593b6454 | |||
| 89590d6120 | |||
| fb0978c9bf | |||
| a3796daa42 | |||
| e3004db666 | |||
| 99cc4e0146 | |||
| a47e6f8fef | |||
| 536d79f765 | |||
| 65c0fbc528 | |||
| 9e4cf4b665 | |||
| 7bea819978 | |||
| 4280233b40 | |||
| d6db3a8a20 | |||
| 51c8ce4df6 | |||
| 5b8393b925 | |||
| e4d9b61fdf | |||
| e2d940cd53 | |||
| 380ccd30b4 | |||
| 31c200716d | |||
| 6add4da543 | |||
| 8ddcd3e853 | |||
| e3e178fd94 | |||
| 7b1ec9f003 | |||
| 1e48c5d473 | |||
| d00ec95dd9 | |||
| 89f3d6cb39 | |||
| a07a2e512a | |||
| 57eecb2b3d | |||
| 9f804dc954 | |||
| 7cbad4c589 | |||
| 5cae93b059 | |||
| df829ad19d | |||
| 635460653c | |||
| 1aa80dc398 | |||
| 8a14056111 | |||
| f247dd8e7b | |||
| 1c7c9754fd | |||
| 5f37939fda | |||
| 28305fcb7a | |||
| 5242803694 | |||
| 925935a7e8 | |||
| 460401c379 | |||
| 348b439172 | |||
| a4e77766c5 | |||
| 4a8c2d7d78 | |||
| 7d51601120 | |||
| 631bbe318b | |||
| 9b7c15d5b6 | |||
| 872796bd9b | |||
| fcf5c27914 |
13
.github/SECURITY.md
vendored
Normal file
13
.github/SECURITY.md
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
|
||||
|
||||
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
|
||||
|
||||
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
|
||||
|
||||
All security vulnerabilities will be promptly addressed.
|
||||
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
8
.github/workflows/static.yml
vendored
8
.github/workflows/static.yml
vendored
@ -24,14 +24,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
dependency-version: [prefer-lowest, prefer-stable]
|
||||
dependency-version: [prefer-stable]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
tools: composer:v2
|
||||
@ -44,7 +44,7 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@ -35,10 +35,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
@ -51,7 +51,7 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
||||
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
|
||||
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
@ -6,10 +6,22 @@ import { createRequire } from 'node:module'
|
||||
import { resolve, relative, extname, sep, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte'])
|
||||
const PAGE_EXTENSIONS = new Set([
|
||||
'.vue', '.svelte',
|
||||
'.tsx', '.jsx',
|
||||
'.ts', '.js',
|
||||
'.mts', '.cts', '.mjs', '.cjs',
|
||||
])
|
||||
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
|
||||
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
||||
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
|
||||
const PAGE_DIR_CANDIDATES = [
|
||||
'resources/js/Pages',
|
||||
'resources/js/pages',
|
||||
'assets/js/Pages',
|
||||
'assets/js/pages',
|
||||
'assets/Pages',
|
||||
'assets/pages',
|
||||
]
|
||||
|
||||
async function loadRolldown() {
|
||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||
@ -64,6 +76,22 @@ async function listPageFiles(pagesDir) {
|
||||
return out
|
||||
}
|
||||
|
||||
async function discoverPagesDir() {
|
||||
const override = process.env.TIA_VITE_PAGES_DIR
|
||||
if (override && override.length > 0) {
|
||||
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
for (const rel of PAGE_DIR_CANDIDATES) {
|
||||
const abs = resolve(PROJECT_ROOT, rel)
|
||||
if (!existsSync(abs)) continue
|
||||
const files = await listPageFiles(abs)
|
||||
if (files.length > 0) return abs
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function componentNameFor(pageAbs, pagesDir) {
|
||||
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
||||
const ext = extname(rel)
|
||||
@ -79,7 +107,13 @@ function isLocalSpecifier(source, aliasKeys) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
|
||||
const pagesDir = await discoverPagesDir()
|
||||
|
||||
if (pagesDir === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const pages = await listPageFiles(pagesDir)
|
||||
|
||||
if (pages.length === 0) {
|
||||
|
||||
@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Kernel;
|
||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
@ -58,6 +59,15 @@ $bootPest = (static function (): void {
|
||||
}
|
||||
}
|
||||
|
||||
$container = Container::getInstance();
|
||||
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
|
||||
}
|
||||
|
||||
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
|
||||
$statusFile = fopen($getopt['status-file'], 'wb');
|
||||
assert(is_resource($statusFile));
|
||||
|
||||
@ -20,19 +20,18 @@
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"composer/xdebug-handler": "^3.0.5",
|
||||
"fidry/cpu-core-counter": "^1.3",
|
||||
"nunomaduro/collision": "^8.9.4",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^4.0.0",
|
||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||
"phpunit/phpunit": "^12.5.24",
|
||||
"symfony/process": "^7.4.8|^8.0.8"
|
||||
"phpunit/phpunit": "^12.5.28",
|
||||
"symfony/process": "^7.4.13|^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.24",
|
||||
"phpunit/phpunit": ">12.5.28",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@ -60,11 +59,11 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"mrpunyapal/peststan": "^0.2.9",
|
||||
"mrpunyapal/peststan": "^0.2.10",
|
||||
"pestphp/pest-dev-tools": "^4.1.0",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||
"psy/psysh": "^0.12.22"
|
||||
"psy/psysh": "^0.12.23"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
@ -94,7 +93,6 @@
|
||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --group=integration -v",
|
||||
"test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||
"test": [
|
||||
"@test:lint",
|
||||
@ -102,8 +100,7 @@
|
||||
"@test:type:coverage",
|
||||
"@test:unit",
|
||||
"@test:parallel",
|
||||
"@test:integration",
|
||||
"@test:tia"
|
||||
"@test:integration"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
||||
19
src/Bootstrappers/BootPhpUnitConfiguration.php
Normal file
19
src/Bootstrappers/BootPhpUnitConfiguration.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Bootstrappers;
|
||||
|
||||
use Pest\Contracts\Bootstrapper;
|
||||
use PHPUnit\TextUI\Configuration\Builder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BootPhpUnitConfiguration implements Bootstrapper
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
(new Builder)->build(['pest']);
|
||||
}
|
||||
}
|
||||
@ -26,8 +26,8 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
||||
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
||||
Subscribers\EnsureTiaCoverageIsFlushed::class,
|
||||
Subscribers\EnsureTiaStarts::class,
|
||||
Subscribers\EnsureTiaEnds::class,
|
||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
||||
|
||||
@ -9,9 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Collectors;
|
||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
||||
use Pest\Plugins\Tia\Enums\ReplayType;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\Replay;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\Container;
|
||||
@ -83,10 +82,15 @@ trait Testable
|
||||
public bool $__ran = false;
|
||||
|
||||
/**
|
||||
* Set when a `BeforeEachable` plugin returns a cached success result.
|
||||
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
|
||||
* The active replay mode for this test, set in `setUp()` and checked
|
||||
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
||||
*/
|
||||
private bool $__cachedPass = false;
|
||||
private ReplayType $__replay = ReplayType::None;
|
||||
|
||||
/**
|
||||
* The cached assertion count to replay, captured when entering replay mode.
|
||||
*/
|
||||
private int $__replayAssertions = 0;
|
||||
|
||||
/**
|
||||
* The test's test closure.
|
||||
@ -240,8 +244,6 @@ trait Testable
|
||||
{
|
||||
TestSuite::getInstance()->test = $this;
|
||||
|
||||
$this->__cachedPass = false;
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
$description = $method->description;
|
||||
@ -277,17 +279,16 @@ trait Testable
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
||||
$replay = Replay::fromStatus($status);
|
||||
$replay = ReplayType::fromStatus($status);
|
||||
|
||||
if ($replay !== Replay::No) {
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
match ($replay) {
|
||||
Replay::Pass => $this->__shortCircuitCachedPass(),
|
||||
Replay::Skipped => $this->markTestSkipped($status->message()),
|
||||
Replay::Incomplete => $this->markTestIncomplete($status->message()),
|
||||
Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
||||
Replay::No => null,
|
||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
|
||||
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
||||
};
|
||||
|
||||
return;
|
||||
@ -300,10 +301,6 @@ trait Testable
|
||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||
}
|
||||
|
||||
$autoloadBeforeSetUp = $recorder->isActive()
|
||||
? AutoloadEdges::snapshot()
|
||||
: [];
|
||||
|
||||
parent::setUp();
|
||||
|
||||
Collectors::armAll($recorder);
|
||||
@ -315,23 +312,12 @@ trait Testable
|
||||
}
|
||||
|
||||
$this->__callClosure($beforeEach, $arguments);
|
||||
|
||||
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
||||
$recorder->linkSourcesForTest(
|
||||
self::$__filename,
|
||||
AutoloadEdges::newProjectFiles(
|
||||
$autoloadBeforeSetUp,
|
||||
AutoloadEdges::snapshot(),
|
||||
TestSuite::getInstance()->rootPath,
|
||||
self::$__filename,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function __shortCircuitCachedPass(): void
|
||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||
{
|
||||
$this->__cachedPass = true;
|
||||
$this->__replay = $replay;
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
@ -367,7 +353,7 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
if ($this->__replay !== ReplayType::None) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
@ -398,19 +384,12 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
// Feed the exact assertion count captured during the recorded
|
||||
// run so Pest's "Tests: N passed (M assertions)" banner stays
|
||||
// accurate on replay instead of collapsing to 1-per-test.
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$assertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||
|
||||
if ($assertions === 0) {
|
||||
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
|
||||
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
||||
$this->expectNotToPerformAssertions();
|
||||
}
|
||||
|
||||
$this->addToAssertionCount($assertions);
|
||||
$this->addToAssertionCount($this->__replayAssertions);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -16,12 +16,12 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $className, private readonly string $file)
|
||||
public function __construct(private readonly string $className, string $filename)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||
$className,
|
||||
$file,
|
||||
$filename,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -166,7 +166,7 @@ final class TestCaseFactory
|
||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||
$traitsCode
|
||||
|
||||
private static \$__filename = '$filename';
|
||||
public static \$__filename = '$filename';
|
||||
|
||||
$methodsCode
|
||||
}
|
||||
|
||||
@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Kernel
|
||||
final class Kernel
|
||||
{
|
||||
/**
|
||||
* Either the kernel is terminated or not.
|
||||
*/
|
||||
private bool $terminated = false;
|
||||
|
||||
/**
|
||||
* The Kernel bootstrappers.
|
||||
*
|
||||
@ -36,6 +41,7 @@ final readonly class Kernel
|
||||
*/
|
||||
private const array BOOTSTRAPPERS = [
|
||||
Bootstrappers\BootOverrides::class,
|
||||
Bootstrappers\BootPhpUnitConfiguration::class,
|
||||
Plugins\Tia\Bootstrapper::class,
|
||||
Bootstrappers\BootSubscribers::class,
|
||||
Bootstrappers\BootFiles::class,
|
||||
@ -59,12 +65,7 @@ final readonly class Kernel
|
||||
/**
|
||||
* Creates a new Kernel instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private Application $application,
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
//
|
||||
}
|
||||
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
|
||||
|
||||
/**
|
||||
* Boots the Kernel.
|
||||
@ -125,9 +126,13 @@ final readonly class Kernel
|
||||
$configuration = Registry::get();
|
||||
$result = Facade::result();
|
||||
|
||||
return CallsAddsOutput::execute(
|
||||
$result = CallsAddsOutput::execute(
|
||||
Result::exitCode($configuration, $result),
|
||||
);
|
||||
|
||||
$this->terminate();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -135,6 +140,12 @@ final readonly class Kernel
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
if ($this->terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->terminated = true;
|
||||
|
||||
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
|
||||
|
||||
assert($preBufferOutput instanceof KernelDump);
|
||||
@ -152,7 +163,7 @@ final readonly class Kernel
|
||||
$this->terminate();
|
||||
|
||||
if (is_array($error = error_get_last())) {
|
||||
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
|
||||
if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Laravel\Pao\Execution;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@ -28,6 +29,10 @@ final class KernelDump
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
if (class_exists(Execution::class) && Execution::running()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ob_start(function (string $message): string {
|
||||
$this->buffer .= $message;
|
||||
|
||||
@ -68,6 +73,10 @@ final class KernelDump
|
||||
|
||||
$type = 'INFO';
|
||||
|
||||
if (is_array($error = error_get_last()) && in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isInternalError($this->buffer)) {
|
||||
$type = 'ERROR';
|
||||
$this->buffer = str_replace(
|
||||
@ -107,7 +116,6 @@ final class KernelDump
|
||||
*/
|
||||
private function isInternalError(string $output): bool
|
||||
{
|
||||
return str_contains($output, 'An error occurred inside PHPUnit.')
|
||||
|| str_contains($output, 'Fatal error');
|
||||
return str_contains($output, 'An error occurred inside PHPUnit.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -954,6 +954,7 @@ final class Expectation
|
||||
} catch (Throwable $e) {
|
||||
|
||||
if ($exception instanceof Throwable) {
|
||||
// @phpstan-ignore-next-line
|
||||
expect($e)
|
||||
->toBeInstanceOf($exception::class, $message)
|
||||
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
||||
|
||||
@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '4.6.3';
|
||||
return '4.7.2';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@ -146,7 +146,6 @@ final class WrapperRunner implements RunnerInterface
|
||||
public function run(): int
|
||||
{
|
||||
$directory = dirname(__DIR__);
|
||||
assert($directory !== '');
|
||||
ExcludeList::addDirectory($directory);
|
||||
TestResultFacade::init();
|
||||
EventFacade::instance()->seal();
|
||||
|
||||
@ -10,6 +10,7 @@ use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Exceptions\NoAffectedTestsFound;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Concerns\HandleArguments;
|
||||
use Pest\Plugins\Tia\BaselineSync;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
@ -19,6 +20,7 @@ use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\JsModuleGraph;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use Pest\Plugins\Tia\SourceScope;
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
use Pest\Plugins\Tia\TableExtractor;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
@ -29,23 +31,38 @@ use Pest\TestSuite;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
use HandleArguments;
|
||||
|
||||
private const string OPTION = '--tia';
|
||||
|
||||
private const string NO_OPTION = '--no-tia';
|
||||
|
||||
private const string FRESH_OPTION = '--fresh';
|
||||
|
||||
private const string REFETCH_OPTION = '--refetch';
|
||||
|
||||
private const string FILTERED_OPTION = '--filtered';
|
||||
|
||||
private const string LOCALLY_OPTION = '--locally';
|
||||
|
||||
private const string BASELINED_OPTION = '--baselined';
|
||||
|
||||
private const string BASELINE_PATH_OPTION = '--baseline';
|
||||
|
||||
private const string ENV_TIA = 'PEST_TIA';
|
||||
|
||||
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
|
||||
|
||||
private const string ENV_LOCALLY = 'PEST_TIA_LOCALLY';
|
||||
|
||||
private const string ENV_BASELINED = 'PEST_TIA_BASELINED';
|
||||
|
||||
public const string KEY_GRAPH = 'graph.json';
|
||||
|
||||
public const string KEY_AFFECTED = 'affected.json';
|
||||
@ -70,6 +87,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
|
||||
|
||||
/**
|
||||
* PHPUnit/Pest CLI flags whose subsequent argument is a value, not a path.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array VALUE_TAKING_FLAGS = [
|
||||
'-c', '--configuration', '--bootstrap', '--cache-directory',
|
||||
'--filter', '--group', '--exclude-group', '--covers', '--uses',
|
||||
'--test-suffix', '--testsuite', '--exclude-testsuite',
|
||||
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
|
||||
'--include-path', '--whitelist',
|
||||
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
||||
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
|
||||
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
|
||||
'--coverage-filter', '--path-coverage',
|
||||
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
||||
'--compact', '--ci-build-id', '--min',
|
||||
];
|
||||
|
||||
private bool $graphWritten = false;
|
||||
|
||||
private bool $replayRan = false;
|
||||
@ -93,16 +129,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
|
||||
private ?array $startFingerprint = null;
|
||||
|
||||
private function workerEdgesKey(string $token): string
|
||||
{
|
||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private function workerResultsKey(string $token): string
|
||||
{
|
||||
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private bool $piggybackCoverage = false;
|
||||
|
||||
private bool $recordingActive = false;
|
||||
@ -115,6 +141,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private bool $filteredMode = false;
|
||||
|
||||
private ?string $driftLabel = null;
|
||||
|
||||
private ?string $driftDetails = null;
|
||||
|
||||
private ?string $freshGraphReason = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
private readonly Recorder $recorder,
|
||||
@ -171,18 +203,65 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
public static function isEnabledForRun(array $arguments): bool
|
||||
{
|
||||
if (in_array(self::OPTION, $arguments, true)) {
|
||||
return true;
|
||||
if (self::argumentPresent(self::NO_OPTION, $arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
assert($watchPatterns instanceof WatchPatterns);
|
||||
|
||||
self::applyWatchPatternMarks($arguments, $watchPatterns);
|
||||
|
||||
if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $watchPatterns->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
|
||||
return ! ($watchPatterns->isLocally() && self::argumentPresent('--ci', $arguments));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
|
||||
{
|
||||
if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) {
|
||||
$watchPatterns->markEnabled();
|
||||
$watchPatterns->markLocally();
|
||||
}
|
||||
|
||||
if (self::argumentPresent(self::BASELINED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_BASELINED)) {
|
||||
$watchPatterns->markBaselined();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors {@see HandleArguments::hasArgument()} for
|
||||
* use from static contexts — matches both `--flag` and `--flag=value`.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private static function argumentPresent(string $argument, array $arguments): bool
|
||||
{
|
||||
foreach ($arguments as $arg) {
|
||||
if ($arg === $argument) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_starts_with($arg, "$argument=")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function envFlagEnabled(string $name): bool
|
||||
{
|
||||
return filter_var(getenv($name), FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
|
||||
public function getStatus(string $filename, string $testId): ?TestStatus
|
||||
@ -239,25 +318,45 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
public function handleArguments(array $arguments): array
|
||||
{
|
||||
if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) {
|
||||
$this->output->writeln(Storage::tempDir(TestSuite::getInstance()->rootPath));
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$isWorker = Parallel::isWorker();
|
||||
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$cliEnabled = $this->hasArgument(self::OPTION, $arguments);
|
||||
self::applyWatchPatternMarks($arguments, $watchPatterns);
|
||||
$disabled = $this->hasArgument(self::NO_OPTION, $arguments);
|
||||
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
|
||||
$alwaysEnabled = $watchPatterns->isEnabled()
|
||||
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
|
||||
$enabled = $cliEnabled || $alwaysEnabled;
|
||||
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered())
|
||||
&& ! $this->hasExplicitPathArgument($arguments);
|
||||
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
|
||||
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
|
||||
&& ! $this->hasExplicitPathArgument($arguments)
|
||||
&& ! $this->coverageReportActive();
|
||||
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
||||
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
|
||||
|
||||
$arguments = $this->popArgument(self::OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::NO_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::LOCALLY_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
|
||||
|
||||
if ($disabled) {
|
||||
$this->forceRefetch = false;
|
||||
$this->filteredMode = false;
|
||||
$this->freshRebuild = false;
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
||||
$this->freshRebuild = $forceRebuild;
|
||||
@ -429,67 +528,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||
);
|
||||
|
||||
$mergedFiles = [];
|
||||
$mergedTables = [];
|
||||
$mergedInertia = [];
|
||||
|
||||
foreach ($partialKeys as $key) {
|
||||
$data = $this->readPartial($key);
|
||||
|
||||
if ($data === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($data['files'] as $testFile => $sources) {
|
||||
if (! isset($mergedFiles[$testFile])) {
|
||||
$mergedFiles[$testFile] = [];
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$mergedFiles[$testFile][$source] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['tables'] as $testFile => $tables) {
|
||||
if (! isset($mergedTables[$testFile])) {
|
||||
$mergedTables[$testFile] = [];
|
||||
}
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$mergedTables[$testFile][$table] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($data['inertia'] as $testFile => $components) {
|
||||
if (! isset($mergedInertia[$testFile])) {
|
||||
$mergedInertia[$testFile] = [];
|
||||
}
|
||||
|
||||
foreach ($components as $component) {
|
||||
$mergedInertia[$testFile][$component] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
$finalised = [];
|
||||
|
||||
foreach ($mergedFiles as $testFile => $sourceSet) {
|
||||
$finalised[$testFile] = array_keys($sourceSet);
|
||||
}
|
||||
|
||||
$finalisedTables = [];
|
||||
|
||||
foreach ($mergedTables as $testFile => $tableSet) {
|
||||
$finalisedTables[$testFile] = array_keys($tableSet);
|
||||
}
|
||||
|
||||
$finalisedInertia = [];
|
||||
|
||||
foreach ($mergedInertia as $testFile => $componentSet) {
|
||||
$finalisedInertia[$testFile] = array_keys($componentSet);
|
||||
}
|
||||
[$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys);
|
||||
|
||||
if ($finalised === []) {
|
||||
if ($this->replayRan) {
|
||||
@ -534,10 +573,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if (! Fingerprint::structuralMatches($stored, $current)) {
|
||||
$drift = Fingerprint::structuralDrift($stored, $current);
|
||||
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'Graph structure outdated (%s).',
|
||||
$this->formatStructuralDrift($drift),
|
||||
));
|
||||
$this->driftLabel = $this->formatStructuralDrift($drift);
|
||||
|
||||
if (in_array('composer_lock', $drift, true)) {
|
||||
$branchSha = $graph->recordedAtSha($this->branch);
|
||||
@ -547,7 +583,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$branchSha,
|
||||
);
|
||||
if ($summary !== '') {
|
||||
$this->renderChild($summary);
|
||||
$this->driftDetails = $summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -607,8 +643,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$branchSha = $graph->recordedAtSha($this->branch);
|
||||
|
||||
if ($changedFiles->gitAvailable()
|
||||
&& $branchSha !== null
|
||||
if ($branchSha !== null
|
||||
&& $changedFiles->since($branchSha) === null) {
|
||||
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
|
||||
$graph = null;
|
||||
@ -618,6 +653,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if (! $graph instanceof Graph
|
||||
&& ! $forceRebuild
|
||||
&& ! $this->baselineFetchAttemptedForDrift
|
||||
&& $this->watchPatterns->isBaselined()
|
||||
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
||||
$this->baselineFetchAttemptedForDrift = true;
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
@ -631,6 +667,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
||||
if ($graph instanceof Graph && $this->driftLabel === null) {
|
||||
$this->freshGraphReason = 'recording coverage baseline';
|
||||
}
|
||||
|
||||
return $this->enterRecordMode($arguments);
|
||||
}
|
||||
|
||||
@ -761,12 +801,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
{
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
|
||||
if (! $changedFiles->gitAvailable()) {
|
||||
$this->renderBadge('WARN', 'Git unavailable — running full suite.');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$branchSha = $graph->recordedAtSha($this->branch);
|
||||
$changed = $changedFiles->since($branchSha) ?? [];
|
||||
|
||||
@ -860,7 +894,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->output->writeln('');
|
||||
|
||||
if ($affected === []) {
|
||||
$this->renderChild('TIA mode enabled.');
|
||||
$this->renderChild('Experimental TIA mode enabled.');
|
||||
|
||||
return;
|
||||
}
|
||||
@ -902,7 +936,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
$this->renderChild(sprintf(
|
||||
'TIA mode enabled / %d affected test file%s%s.',
|
||||
'Experimental TIA mode enabled / %d affected test file%s%s.',
|
||||
count($affected),
|
||||
count($affected) === 1 ? '' : 's',
|
||||
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
|
||||
@ -963,7 +997,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
$this->output->writeln('');
|
||||
$this->renderChild('TIA mode enabled / fresh graph.');
|
||||
$this->renderFreshGraph();
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@ -972,7 +1006,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->recordingActive = true;
|
||||
|
||||
$this->output->writeln('');
|
||||
$this->renderChild('TIA mode enabled / fresh graph.');
|
||||
$this->renderFreshGraph();
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@ -985,11 +1019,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
private function renderFreshGraph(): void
|
||||
{
|
||||
$headline = 'Experimental TIA mode enabled / fresh graph';
|
||||
|
||||
if ($this->driftLabel !== null) {
|
||||
$headline .= sprintf(' (%s changed)', $this->driftLabel);
|
||||
} elseif ($this->freshGraphReason !== null) {
|
||||
$headline .= sprintf(' (%s)', $this->freshGraphReason);
|
||||
} else {
|
||||
$headline .= '.';
|
||||
}
|
||||
|
||||
$this->renderChild($headline);
|
||||
|
||||
if ($this->driftDetails !== null) {
|
||||
foreach (explode(', ', $this->driftDetails) as $detail) {
|
||||
$this->output->writeln(sprintf(' <fg=gray>%s</>', $detail));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function emitCoverageDriverMissing(): void
|
||||
{
|
||||
$this->renderBadge('WARN', 'No coverage driver is available — skipped.');
|
||||
$this->renderChild('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.');
|
||||
$this->renderChild('Install or enable one and rerun with --tia.');
|
||||
$this->output->writeln('');
|
||||
|
||||
$this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1009,7 +1064,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$this->state->write($this->workerEdgesKey($this->workerToken()), $json);
|
||||
$this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1071,7 +1126,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$this->state->write($this->workerResultsKey($this->workerToken()), $json);
|
||||
$this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1157,6 +1212,43 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $partialKeys
|
||||
* @return array{0: array<string, list<string>>, 1: array<string, list<string>>, 2: array<string, list<string>>}
|
||||
*/
|
||||
private function consumePartials(array $partialKeys): array
|
||||
{
|
||||
$merged = ['files' => [], 'tables' => [], 'inertia' => []];
|
||||
|
||||
foreach ($partialKeys as $key) {
|
||||
$data = $this->readPartial($key);
|
||||
|
||||
if ($data === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (['files', 'tables', 'inertia'] as $section) {
|
||||
foreach ($data[$section] as $testFile => $values) {
|
||||
if (! isset($merged[$section][$testFile])) {
|
||||
$merged[$section][$testFile] = [];
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
$merged[$section][$testFile][$value] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
return [
|
||||
array_map(array_keys(...), $merged['files']),
|
||||
array_map(array_keys(...), $merged['tables']),
|
||||
array_map(array_keys(...), $merged['inertia']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
|
||||
*/
|
||||
@ -1270,7 +1362,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
foreach ($collector->all() as $testId => $result) {
|
||||
$results = $collector->all();
|
||||
$touchedFiles = [];
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$touchedFiles[$file] = true;
|
||||
}
|
||||
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
@ -1278,10 +1379,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$result['message'],
|
||||
$result['time'],
|
||||
$result['assertions'],
|
||||
$result['file'] ?? null,
|
||||
$file,
|
||||
);
|
||||
}
|
||||
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
@ -1304,6 +1407,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$touchedFiles = [];
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
@ -1311,6 +1416,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$file = $this->resolveFailedTestFile($testId);
|
||||
}
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$touchedFiles[$file] = true;
|
||||
}
|
||||
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
@ -1322,6 +1431,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
);
|
||||
}
|
||||
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
|
||||
$this->saveGraph($graph);
|
||||
$collector->reset();
|
||||
}
|
||||
@ -1334,21 +1445,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($class);
|
||||
assert(property_exists($class, '__filename') && is_string($class::$__filename));
|
||||
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
try {
|
||||
$filename = $reflection->getStaticPropertyValue('__filename');
|
||||
} catch (\ReflectionException) {
|
||||
$filename = null;
|
||||
}
|
||||
$filename = $class::$__filename;
|
||||
|
||||
if (is_string($filename) && $filename !== '' && ! str_contains($filename, "eval()'d")) {
|
||||
return $filename;
|
||||
}
|
||||
if ($filename !== '' && ! str_contains($filename, "eval()'d")) {
|
||||
return $filename;
|
||||
}
|
||||
|
||||
$current = $reflection;
|
||||
$current = new \ReflectionClass($class);
|
||||
|
||||
while ($current !== false) {
|
||||
$file = $current->getFileName();
|
||||
@ -1365,14 +1470,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private function coverageReportActive(): bool
|
||||
{
|
||||
try {
|
||||
/** @var Coverage $coverage */
|
||||
$coverage = Container::getInstance()->get(Coverage::class);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
$coverage = Container::getInstance()->get(Coverage::class);
|
||||
assert($coverage instanceof Coverage);
|
||||
|
||||
return $coverage->coverage === true;
|
||||
return $coverage->coverage;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1380,35 +1481,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function hasExplicitPathArgument(array $arguments): bool
|
||||
{
|
||||
static $valueTakingFlags = [
|
||||
'-c', '--configuration', '--bootstrap', '--cache-directory',
|
||||
'--filter', '--group', '--exclude-group', '--covers', '--uses',
|
||||
'--test-suffix', '--testsuite', '--exclude-testsuite',
|
||||
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
|
||||
'--include-path', '--whitelist',
|
||||
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
||||
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
|
||||
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
|
||||
'--coverage-filter', '--path-coverage',
|
||||
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
||||
'--compact', '--ci-build-id', '--min',
|
||||
];
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$testPaths = \Pest\Plugins\Tia\SourceScope::testPaths($projectRoot);
|
||||
$testPaths = SourceScope::testPaths();
|
||||
|
||||
if ($testPaths === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($arguments as $index => $arg) {
|
||||
if ($arg === '' || str_starts_with($arg, '-')) {
|
||||
if ($arg === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($arg, '-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($index > 0) {
|
||||
$previous = $arguments[$index - 1] ?? '';
|
||||
if (in_array($previous, $valueTakingFlags, true)) {
|
||||
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@ -1494,6 +1583,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$this->baselineFetchAttemptedForDrift = true;
|
||||
|
||||
if (! $this->watchPatterns->isBaselined()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
|
||||
return null;
|
||||
}
|
||||
@ -1505,7 +1598,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
|
||||
$this->renderBadge('WARN', 'Fetched baseline still drifts — discarding.');
|
||||
$this->output->writeln(' <fg=gray> However, baseline still drifts — discarding.</>');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Exceptions\BaselineFetchFailed;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
@ -32,6 +31,29 @@ final readonly class BaselineSync
|
||||
|
||||
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||
|
||||
private const array DIAGNOSES = [
|
||||
'network' => [
|
||||
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
|
||||
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
||||
],
|
||||
'gh-auth' => [
|
||||
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
|
||||
'message' => 'authentication failed — run `gh auth login` and retry.',
|
||||
],
|
||||
'rate-limit' => [
|
||||
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
|
||||
'message' => 'GitHub API rate limit hit — try again later.',
|
||||
],
|
||||
'not-found' => [
|
||||
'pattern' => '/404|not found|repository not found/i',
|
||||
'message' => 'workflow or artifact not found in repo.',
|
||||
],
|
||||
'forbidden' => [
|
||||
'pattern' => '/403|forbidden|access denied/i',
|
||||
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private State $state,
|
||||
private OutputInterface $output,
|
||||
@ -64,13 +86,14 @@ final readonly class BaselineSync
|
||||
return false;
|
||||
}
|
||||
|
||||
$failureKind = null;
|
||||
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
|
||||
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
||||
$payload = $result['payload'];
|
||||
$failureKind = $result['failureKind'];
|
||||
|
||||
if ($payload === null) {
|
||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions($repo);
|
||||
$this->emitPublishInstructions();
|
||||
}
|
||||
|
||||
return false;
|
||||
@ -86,11 +109,6 @@ final readonly class BaselineSync
|
||||
|
||||
$this->clearCooldown();
|
||||
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'Baseline ready (%s).',
|
||||
$this->formatSize($payload['sizeOnDisk']),
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -138,7 +156,7 @@ final readonly class BaselineSync
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
private function emitPublishInstructions(string $repo): void
|
||||
private function emitPublishInstructions(): void
|
||||
{
|
||||
if ($this->isCi()) {
|
||||
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
||||
@ -146,23 +164,8 @@ final readonly class BaselineSync
|
||||
return;
|
||||
}
|
||||
|
||||
$yaml = $this->isLaravel()
|
||||
? $this->laravelWorkflowYaml()
|
||||
: $this->genericWorkflowYaml();
|
||||
|
||||
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
||||
$this->renderChild('To share the baseline with your team, add this workflow to the repo:');
|
||||
$this->renderChild('.github/workflows/tia-baseline.yml');
|
||||
|
||||
$indentedYaml = array_map(
|
||||
static fn (string $line): string => ' '.$line,
|
||||
explode("\n", $yaml),
|
||||
);
|
||||
|
||||
$this->output->writeln(['', ...$indentedYaml, '']);
|
||||
|
||||
$this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
|
||||
$this->renderChild('Details: https://pestphp.com/docs/tia/ci');
|
||||
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
|
||||
}
|
||||
|
||||
private function isCi(): bool
|
||||
@ -172,79 +175,6 @@ final readonly class BaselineSync
|
||||
|| getenv('CIRCLECI') === 'true';
|
||||
}
|
||||
|
||||
private function isLaravel(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
|
||||
private function laravelWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
name: TIA Baseline
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
schedule: [{ cron: '0 3 * * *' }]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
coverage: xdebug
|
||||
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
|
||||
- run: cp .env.example .env
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: php artisan key:generate
|
||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||
- name: Stage baseline for upload
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p .pest-tia-baseline
|
||||
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: .pest-tia-baseline/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
private function genericWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
name: TIA Baseline
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
schedule: [{ cron: '0 3 * * *' }]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with: { php-version: '8.4', coverage: xdebug }
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||
- name: Stage baseline for upload
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p .pest-tia-baseline
|
||||
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: .pest-tia-baseline/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
private function detectGitHubRepo(string $projectRoot): ?string
|
||||
{
|
||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
@ -281,14 +211,78 @@ YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param-out string|null $failureKind
|
||||
*
|
||||
* @return array{graph: string, coverage: ?string}|null
|
||||
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
|
||||
*/
|
||||
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
|
||||
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
||||
{
|
||||
$failureKind = null;
|
||||
$this->validateGhDependencies($hasAnchor);
|
||||
|
||||
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
||||
|
||||
if ($listError !== null) {
|
||||
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
));
|
||||
|
||||
return ['payload' => null, 'failureKind' => $listError['kind']];
|
||||
}
|
||||
|
||||
if ($runId === null) {
|
||||
return ['payload' => null, 'failureKind' => 'no-runs'];
|
||||
}
|
||||
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
||||
@touch($runCacheDir);
|
||||
|
||||
$this->renderChild(sprintf(
|
||||
'Using cached baseline from %s (run %s).',
|
||||
$repo,
|
||||
$runId,
|
||||
));
|
||||
|
||||
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
|
||||
}
|
||||
|
||||
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
||||
return ['payload' => null, 'failureKind' => null];
|
||||
}
|
||||
|
||||
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
|
||||
|
||||
if (! $download['success']) {
|
||||
return ['payload' => null, 'failureKind' => $download['failureKind']];
|
||||
}
|
||||
|
||||
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
|
||||
|
||||
$this->trimDownloadCache($projectRoot);
|
||||
|
||||
return ['payload' => $payload, 'failureKind' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{kind: string, message: string} $diagnosis
|
||||
*/
|
||||
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
|
||||
{
|
||||
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
|
||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
private function validateGhDependencies(bool $hasAnchor): void
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||
@ -304,62 +298,24 @@ YAML;
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
||||
|
||||
if ($listError !== null) {
|
||||
$failureKind = $listError['kind'];
|
||||
|
||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
sprintf('Failed to query baseline runs — %s', $listError['message']),
|
||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($runId === null) {
|
||||
$failureKind = 'no-runs';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
||||
@touch($runCacheDir);
|
||||
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'Using cached baseline from %s (run %s).',
|
||||
$repo,
|
||||
$runId,
|
||||
));
|
||||
|
||||
return $this->readArtifact($runCacheDir);
|
||||
}
|
||||
|
||||
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, failureKind: ?string}
|
||||
*/
|
||||
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
|
||||
{
|
||||
$artifactSize = $this->artifactSize($repo, $runId);
|
||||
|
||||
$this->renderBadge('INFO', $artifactSize !== null
|
||||
$this->output->writeln('');
|
||||
$this->renderChild($artifactSize !== null
|
||||
? sprintf(
|
||||
'Fetching baseline (%s) from %s…',
|
||||
'Downloading TIA baseline (%s) from %s…',
|
||||
$this->formatSize($artifactSize),
|
||||
$repo,
|
||||
)
|
||||
: sprintf(
|
||||
'Fetching baseline from %s…',
|
||||
'Downloading TIA baseline from %s…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
@ -373,37 +329,39 @@ YAML;
|
||||
$process->start();
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$tick = 0;
|
||||
|
||||
while ($process->isRunning()) {
|
||||
$this->renderDownloadProgress($runCacheDir, $artifactSize, $startedAt);
|
||||
usleep(250_000);
|
||||
$this->renderDownloadProgress($startedAt, $tick++);
|
||||
usleep(120_000);
|
||||
}
|
||||
|
||||
$process->wait();
|
||||
$this->clearProgressLine();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||
$failureKind = $diagnosis['kind'];
|
||||
|
||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
sprintf('Baseline download failed — %s', $diagnosis['message']),
|
||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Baseline download failed — %s',
|
||||
$diagnosis['message'],
|
||||
));
|
||||
|
||||
return null;
|
||||
if ($process->isSuccessful()) {
|
||||
return ['success' => true, 'failureKind' => null];
|
||||
}
|
||||
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||
|
||||
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Baseline download failed — %s',
|
||||
$diagnosis['message'],
|
||||
));
|
||||
|
||||
return ['success' => false, 'failureKind' => $diagnosis['kind']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
|
||||
*/
|
||||
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
|
||||
{
|
||||
$payload = $this->readArtifact($runCacheDir);
|
||||
|
||||
if ($payload === null) {
|
||||
@ -416,8 +374,6 @@ YAML;
|
||||
));
|
||||
}
|
||||
|
||||
$this->trimDownloadCache($projectRoot);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
@ -443,30 +399,18 @@ YAML;
|
||||
return is_numeric($size) ? (int) $size : null;
|
||||
}
|
||||
|
||||
private function renderDownloadProgress(string $dir, ?int $totalBytes, float $startedAt): void
|
||||
private function renderDownloadProgress(float $startedAt, int $tick): void
|
||||
{
|
||||
$current = $this->dirSize($dir);
|
||||
$elapsed = max(0.001, microtime(true) - $startedAt);
|
||||
$speed = (int) ($current / $elapsed);
|
||||
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
if ($totalBytes !== null && $totalBytes > 0) {
|
||||
$percent = min(99, (int) floor(($current / $totalBytes) * 100));
|
||||
$message = sprintf(
|
||||
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
|
||||
$this->formatSize($current),
|
||||
$this->formatSize($totalBytes),
|
||||
$percent,
|
||||
$this->formatSize($speed),
|
||||
);
|
||||
} else {
|
||||
$message = sprintf(
|
||||
' <fg=cyan>Downloading</> %s (%s/s)',
|
||||
$this->formatSize($current),
|
||||
$this->formatSize($speed),
|
||||
);
|
||||
}
|
||||
$elapsed = max(0.0, microtime(true) - $startedAt);
|
||||
$frame = $frames[$tick % count($frames)];
|
||||
|
||||
$this->output->write("\r\033[K".$message);
|
||||
$this->output->write(sprintf(
|
||||
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
|
||||
$frame,
|
||||
$elapsed,
|
||||
));
|
||||
}
|
||||
|
||||
private function clearProgressLine(): void
|
||||
@ -548,12 +492,10 @@ YAML;
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
if (in_array($entry, ['.', '..'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
@ -624,59 +566,21 @@ YAML;
|
||||
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
||||
}
|
||||
|
||||
if (preg_match('/(could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo)/i', $output) === 1) {
|
||||
return [
|
||||
'kind' => 'network',
|
||||
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
||||
];
|
||||
foreach (self::DIAGNOSES as $kind => $diagnosis) {
|
||||
if (preg_match($diagnosis['pattern'], $output) === 1) {
|
||||
return ['kind' => $kind, 'message' => $diagnosis['message']];
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/(authentication failed|not logged in|requires authentication|bad credentials|401)/i', $output) === 1) {
|
||||
return [
|
||||
'kind' => 'gh-auth',
|
||||
'message' => 'authentication failed — run `gh auth login` and retry.',
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/(rate limit|too many requests|secondary rate limit)/i', $output) === 1) {
|
||||
return [
|
||||
'kind' => 'rate-limit',
|
||||
'message' => 'GitHub API rate limit hit — try again later.',
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/(404|not found|repository not found)/i', $output) === 1) {
|
||||
return [
|
||||
'kind' => 'not-found',
|
||||
'message' => 'workflow or artifact not found in repo.',
|
||||
];
|
||||
}
|
||||
|
||||
if (preg_match('/(403|forbidden|access denied)/i', $output) === 1) {
|
||||
return [
|
||||
'kind' => 'forbidden',
|
||||
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
||||
];
|
||||
}
|
||||
|
||||
$message = trim(strtok($output, "\n"));
|
||||
|
||||
return ['kind' => 'unknown', 'message' => $message];
|
||||
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
|
||||
}
|
||||
|
||||
private function commandExists(string $cmd): bool
|
||||
{
|
||||
$probe = new Process(['command', '-v', $cmd]);
|
||||
$probe->run();
|
||||
$process = new Process(['which', $cmd]);
|
||||
$process->run();
|
||||
|
||||
if ($probe->isSuccessful()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$which = new Process(['which', $cmd]);
|
||||
$which->run();
|
||||
|
||||
return $which->isSuccessful();
|
||||
return $process->isSuccessful();
|
||||
}
|
||||
|
||||
private function cleanup(string $dir): void
|
||||
@ -685,13 +589,17 @@ YAML;
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
|
||||
if ($entries !== false) {
|
||||
foreach ($entries as $entry) {
|
||||
if (is_file($entry)) {
|
||||
@unlink($entry);
|
||||
}
|
||||
/** @var \SplFileInfo $entry */
|
||||
foreach ($iterator as $entry) {
|
||||
if ($entry->isDir()) {
|
||||
@rmdir($entry->getPathname());
|
||||
} else {
|
||||
@unlink($entry->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,18 +17,12 @@ final readonly class Bootstrapper implements BootstrapperContract
|
||||
public function __construct(private Container $container) {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->container->add(State::class, new FileState($this->tempDir()));
|
||||
}
|
||||
|
||||
/**
|
||||
* across worktrees of the same repo. See {@see Storage} for the key
|
||||
*/
|
||||
private function tempDir(): string
|
||||
{
|
||||
$testSuite = $this->container->get(TestSuite::class);
|
||||
assert($testSuite instanceof TestSuite);
|
||||
|
||||
return Storage::tempDir($testSuite->rootPath);
|
||||
$tempDir = Storage::tempDir($testSuite->rootPath);
|
||||
|
||||
$this->container->add(State::class, new FileState($tempDir));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
@ -34,39 +35,29 @@ final readonly class ChangedFiles
|
||||
|
||||
foreach (array_keys($candidates) as $file) {
|
||||
$snapshot = $lastRunTree[$file] ?? null;
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
$exists = is_file($absolute);
|
||||
$current = $this->currentHash($file);
|
||||
|
||||
if ($snapshot === null) {
|
||||
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $exists) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
if ($hash === false) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hash === $snapshot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$remaining[] = $file;
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
private function currentHash(string $relativePath): ?string
|
||||
{
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $files
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
@ -99,10 +90,6 @@ final readonly class ChangedFiles
|
||||
*/
|
||||
public function since(?string $sha): ?array
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
if ($sha !== null && $sha !== '') {
|
||||
@ -121,13 +108,10 @@ final readonly class ChangedFiles
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->shouldIgnore($file)) {
|
||||
continue;
|
||||
}
|
||||
$unique[$file] = true;
|
||||
}
|
||||
|
||||
$candidates = array_keys($unique);
|
||||
$candidates = array_keys($this->filterIgnored($unique));
|
||||
|
||||
if ($sha !== null && $sha !== '') {
|
||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||
@ -145,17 +129,9 @@ final readonly class ChangedFiles
|
||||
$remaining = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
$currentHash = $this->currentHash($file);
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentHash = ContentHash::of($absolute);
|
||||
|
||||
if ($currentHash === false) {
|
||||
if ($currentHash === null) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@ -169,9 +145,7 @@ final readonly class ChangedFiles
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineHash = ContentHash::ofContent($file, $baselineContent);
|
||||
|
||||
if ($currentHash !== $baselineHash) {
|
||||
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
}
|
||||
@ -192,37 +166,52 @@ final readonly class ChangedFiles
|
||||
return $process->getOutput();
|
||||
}
|
||||
|
||||
private function shouldIgnore(string $path): bool
|
||||
/**
|
||||
* @param array<string, true> $candidates
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function filterIgnored(array $candidates): array
|
||||
{
|
||||
static $prefixes = [
|
||||
'.pest/',
|
||||
'.phpunit.cache/',
|
||||
'.phpunit.result.cache',
|
||||
'vendor/',
|
||||
'node_modules/',
|
||||
'bootstrap/cache/',
|
||||
];
|
||||
if ($candidates === []) {
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($path, (string) $prefix)) {
|
||||
return true;
|
||||
$process = new Process(
|
||||
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->setTimeout(5.0);
|
||||
$process->setInput(implode("\x00", array_keys($candidates)));
|
||||
$process->run();
|
||||
|
||||
$exitCode = $process->getExitCode();
|
||||
|
||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
|
||||
if ($output === '') {
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
|
||||
if ($ignored !== '') {
|
||||
unset($candidates[$ignored]);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
public function currentBranch(): ?string
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$branch = trim($process->getOutput());
|
||||
@ -230,14 +219,6 @@ final readonly class ChangedFiles
|
||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||
}
|
||||
|
||||
public function gitAvailable(): bool
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful();
|
||||
}
|
||||
|
||||
private function shaIsReachable(string $sha): bool
|
||||
{
|
||||
$process = new Process(
|
||||
@ -261,7 +242,7 @@ final readonly class ChangedFiles
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
return $this->splitLines($process->getOutput());
|
||||
@ -279,7 +260,7 @@ final readonly class ChangedFiles
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
@ -321,15 +302,11 @@ final readonly class ChangedFiles
|
||||
|
||||
public function currentSha(): ?string
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$sha = trim($process->getOutput());
|
||||
|
||||
@ -48,6 +48,18 @@ final class Configuration
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function baselined(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markBaselined();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $patterns glob → project-relative test dir
|
||||
* @return $this
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -12,7 +12,7 @@ interface WatchDefault
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> glob → list of project-relative test dirs
|
||||
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
|
||||
*/
|
||||
public function defaults(string $projectRoot, string $testPath): array;
|
||||
}
|
||||
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
|
||||
use ReflectionClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@ -104,22 +103,8 @@ final class CoverageCollector
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
$property = $reflection->getProperty('__filename');
|
||||
|
||||
if ($property->isStatic()) {
|
||||
$value = $property->getValue();
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$file = $reflection->getFileName();
|
||||
|
||||
return is_string($file) ? $file : null;
|
||||
return $className::$__filename;
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ final class CoverageMerger
|
||||
{
|
||||
$state = self::state();
|
||||
|
||||
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ final class CoverageMerger
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current instanceof CodeCoverage) {
|
||||
self::primeUncoveredFiles($current);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
||||
}
|
||||
|
||||
@ -52,6 +53,9 @@ final class CoverageMerger
|
||||
return;
|
||||
}
|
||||
|
||||
self::primeUncoveredFiles($cached);
|
||||
self::primeUncoveredFiles($current);
|
||||
|
||||
self::stripCurrentTestsFromCached($cached, $current);
|
||||
|
||||
$cached->merge($current);
|
||||
@ -65,6 +69,11 @@ final class CoverageMerger
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
|
||||
}
|
||||
|
||||
private static function primeUncoveredFiles(CodeCoverage $coverage): void
|
||||
{
|
||||
$coverage->getData(false);
|
||||
}
|
||||
|
||||
private static function compress(string $bytes): string
|
||||
{
|
||||
$compressed = @gzencode($bytes);
|
||||
@ -131,15 +140,12 @@ final class CoverageMerger
|
||||
return array_keys($ids);
|
||||
}
|
||||
|
||||
private static function state(): ?State
|
||||
private static function state(): State
|
||||
{
|
||||
try {
|
||||
$state = Container::getInstance()->get(State::class);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
$state = Container::getInstance()->get(State::class);
|
||||
assert($state instanceof State);
|
||||
|
||||
return $state instanceof State ? $state : null;
|
||||
return $state;
|
||||
}
|
||||
|
||||
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class AutoloadEdges
|
||||
{
|
||||
/**
|
||||
* @return array<string, true>
|
||||
*/
|
||||
public static function snapshot(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach (get_included_files() as $file) {
|
||||
if ($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, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -34,11 +34,7 @@ final class BladeEdges
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $app->bound('view')) {
|
||||
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -36,11 +36,7 @@ final class InertiaEdges
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $app->bound('events')) {
|
||||
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -54,18 +50,11 @@ final class InertiaEdges
|
||||
}
|
||||
|
||||
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
||||
if (! property_exists($event, 'response')) {
|
||||
if (! property_exists($event, 'response') || ! is_object($event->response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var mixed $response */
|
||||
$response = $event->response;
|
||||
|
||||
if (! is_object($response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component = self::extractComponent($response);
|
||||
$component = self::extractComponent($event->response);
|
||||
|
||||
if ($component !== null) {
|
||||
$recorder->linkInertiaComponent($component);
|
||||
@ -75,32 +64,16 @@ final class InertiaEdges
|
||||
|
||||
private static function extractComponent(object $response): ?string
|
||||
{
|
||||
if (property_exists($response, 'headers') && is_object($response->headers)) {
|
||||
$headers = $response->headers;
|
||||
|
||||
if (method_exists($headers, 'has') && $headers->has('X-Inertia')) {
|
||||
$content = self::readContent($response);
|
||||
|
||||
if ($content !== null) {
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($content, true);
|
||||
|
||||
if (is_array($decoded)
|
||||
&& isset($decoded['component'])
|
||||
&& is_string($decoded['component'])
|
||||
&& $decoded['component'] !== '') {
|
||||
return $decoded['component'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$content = self::readContent($response);
|
||||
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::isInertiaJsonResponse($response)) {
|
||||
return self::componentFromJson($content);
|
||||
}
|
||||
|
||||
if (str_contains($content, 'type="application/json"')
|
||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
@ -112,16 +85,23 @@ final class InertiaEdges
|
||||
|
||||
if (str_contains($content, 'data-page=')
|
||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
return self::componentFromJson(html_entity_decode($match[1]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isInertiaJsonResponse(object $response): bool
|
||||
{
|
||||
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$headers = $response->headers;
|
||||
|
||||
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
|
||||
}
|
||||
|
||||
private static function componentFromJson(string $json): ?string
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
|
||||
@ -2,17 +2,18 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
namespace Pest\Plugins\Tia\Enums;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
enum Replay
|
||||
enum ReplayType
|
||||
{
|
||||
case No;
|
||||
case None;
|
||||
case Pass;
|
||||
case Risky;
|
||||
case Skipped;
|
||||
case Incomplete;
|
||||
case Failure;
|
||||
@ -20,11 +21,12 @@ enum Replay
|
||||
public static function fromStatus(?TestStatus $status): self
|
||||
{
|
||||
if (! $status instanceof TestStatus) {
|
||||
return self::No;
|
||||
return self::None;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$status->isSuccess(), $status->isRisky() => self::Pass,
|
||||
$status->isSuccess() => self::Pass,
|
||||
$status->isRisky() => self::Risky,
|
||||
$status->isSkipped() => self::Skipped,
|
||||
$status->isIncomplete() => self::Incomplete,
|
||||
default => self::Failure,
|
||||
@ -9,9 +9,11 @@ use Pest\Plugins\Tia\Contracts\State;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class FileState implements State
|
||||
final class FileState implements State
|
||||
{
|
||||
private string $rootDir;
|
||||
private readonly string $rootDir;
|
||||
|
||||
private ?string $resolvedRoot = null;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
@ -100,9 +102,17 @@ final readonly class FileState implements State
|
||||
|
||||
private function resolvedRoot(): ?string
|
||||
{
|
||||
if ($this->resolvedRoot !== null) {
|
||||
return $this->resolvedRoot;
|
||||
}
|
||||
|
||||
$resolved = @realpath($this->rootDir);
|
||||
|
||||
return $resolved === false ? null : $resolved;
|
||||
if ($resolved === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolvedRoot = $resolved;
|
||||
}
|
||||
|
||||
private function ensureRoot(): bool
|
||||
|
||||
@ -4,17 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
private const int SCHEMA_VERSION = 14;
|
||||
private const int SCHEMA_VERSION = 17;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* structural: array<string, int|string|null>,
|
||||
* environmental: array<string, string|null>,
|
||||
* environmental: array<string, int|string|null>,
|
||||
* }
|
||||
*/
|
||||
public static function compute(string $projectRoot): array
|
||||
@ -22,17 +24,22 @@ final readonly class Fingerprint
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
|
||||
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
'vite_config' => self::viteConfigHash($projectRoot),
|
||||
'package_json' => self::packageJsonHash($projectRoot),
|
||||
// 'package_json' => self::packageJsonHash($projectRoot),
|
||||
'package_lock' => self::packageLockHash($projectRoot),
|
||||
'js_config' => self::jsConfigHash($projectRoot),
|
||||
'composer_json' => self::composerJsonHash($projectRoot),
|
||||
// 'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'environmental' => [
|
||||
'php_minor' => PHP_MAJOR_VERSION,
|
||||
|
||||
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
||||
// 'env_files' => self::envFilesHash($projectRoot),
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -59,30 +66,11 @@ final readonly class Fingerprint
|
||||
*/
|
||||
public static function structuralDrift(array $stored, array $current): array
|
||||
{
|
||||
$a = self::structuralOnly($stored);
|
||||
$b = self::structuralOnly($current);
|
||||
|
||||
$drifts = [];
|
||||
|
||||
foreach ($a as $key => $value) {
|
||||
if ($key === 'schema') {
|
||||
continue;
|
||||
}
|
||||
if (($b[$key] ?? null) !== $value) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($b as $key => $value) {
|
||||
if ($key === 'schema') {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($key, $a) && $value !== null) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($drifts));
|
||||
return self::detectDrift(
|
||||
self::structuralOnly($stored),
|
||||
self::structuralOnly($current),
|
||||
'schema',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,18 +80,34 @@ final readonly class Fingerprint
|
||||
*/
|
||||
public static function environmentalDrift(array $stored, array $current): array
|
||||
{
|
||||
$a = self::environmentalOnly($stored);
|
||||
$b = self::environmentalOnly($current);
|
||||
return self::detectDrift(
|
||||
self::environmentalOnly($stored),
|
||||
self::environmentalOnly($current),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
|
||||
{
|
||||
$drifts = [];
|
||||
|
||||
foreach ($a as $key => $value) {
|
||||
if ($key === $skipKey) {
|
||||
continue;
|
||||
}
|
||||
if (($b[$key] ?? null) !== $value) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($b as $key => $value) {
|
||||
if ($key === $skipKey) {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($key, $a) && $value !== null) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
@ -157,7 +161,11 @@ final readonly class Fingerprint
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
@ -173,6 +181,10 @@ final readonly class Fingerprint
|
||||
$parts = [];
|
||||
|
||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
@ -183,47 +195,9 @@ final readonly class Fingerprint
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function packageJsonHash(string $projectRoot): ?string
|
||||
private static function composerLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/package.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$relevant = [
|
||||
'type' => $data['type'] ?? null,
|
||||
'packageManager' => $data['packageManager'] ?? null,
|
||||
'dependencies' => $data['dependencies'] ?? null,
|
||||
'devDependencies' => $data['devDependencies'] ?? null,
|
||||
'optionalDependencies' => $data['optionalDependencies'] ?? null,
|
||||
'peerDependencies' => $data['peerDependencies'] ?? null,
|
||||
'overrides' => $data['overrides'] ?? null,
|
||||
'resolutions' => $data['resolutions'] ?? null,
|
||||
'imports' => $data['imports'] ?? null,
|
||||
'exports' => $data['exports'] ?? null,
|
||||
'browser' => $data['browser'] ?? null,
|
||||
];
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
|
||||
$json = json_encode($relevant);
|
||||
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
return self::trackedHash($projectRoot, 'composer.lock');
|
||||
}
|
||||
|
||||
private static function packageLockHash(string $projectRoot): ?string
|
||||
@ -231,7 +205,7 @@ final readonly class Fingerprint
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
$hash = self::trackedHash($projectRoot, $name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
@ -241,68 +215,47 @@ final readonly class Fingerprint
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function composerJsonHash(string $projectRoot): ?string
|
||||
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||
$relevantConfig = array_intersect_key($config, [
|
||||
'platform' => true,
|
||||
'allow-plugins' => true,
|
||||
]);
|
||||
|
||||
$relevant = [
|
||||
'autoload' => $data['autoload'] ?? null,
|
||||
'autoload-dev' => $data['autoload-dev'] ?? null,
|
||||
'require' => $data['require'] ?? null,
|
||||
'require-dev' => $data['require-dev'] ?? null,
|
||||
'extra' => $data['extra'] ?? null,
|
||||
'repositories' => $data['repositories'] ?? null,
|
||||
'minimum-stability' => $data['minimum-stability'] ?? null,
|
||||
'prefer-stable' => $data['prefer-stable'] ?? null,
|
||||
'config' => $relevantConfig === [] ? null : $relevantConfig,
|
||||
];
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
|
||||
$json = json_encode($relevant);
|
||||
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
return self::hashIfExists($projectRoot.'/'.$relativePath);
|
||||
}
|
||||
|
||||
private static function sortRecursively(mixed &$value): void
|
||||
/**
|
||||
* Returns true when the file exists and is not gitignored.
|
||||
*
|
||||
* Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
|
||||
* regenerate per-machine with OS-specific optional deps, which would
|
||||
* otherwise force a fingerprint mismatch on every fetched baseline.
|
||||
*/
|
||||
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
if (! is_file($projectRoot.'/'.$relativePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$isAssoc = ! array_is_list($value);
|
||||
static $cache = [];
|
||||
|
||||
if ($isAssoc) {
|
||||
ksort($value);
|
||||
$key = $projectRoot."\0".$relativePath;
|
||||
|
||||
if (isset($cache[$key])) {
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
foreach ($value as &$child) {
|
||||
self::sortRecursively($child);
|
||||
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
|
||||
return $cache[$key] = true;
|
||||
}
|
||||
|
||||
$finder = (new Finder)
|
||||
->in($projectRoot)
|
||||
->depth('== 0')
|
||||
->name($relativePath)
|
||||
->ignoreVCSIgnored(true);
|
||||
|
||||
return $cache[$key] = $finder->hasResults();
|
||||
}
|
||||
|
||||
private static function contentHashOrNull(string $path): ?string
|
||||
|
||||
@ -5,11 +5,13 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\View;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use PHPUnit\TextUI\Configuration\Registry;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -51,6 +53,9 @@ final class Graph
|
||||
/** @var array<string, true>|null */
|
||||
private ?array $archTestFiles = null;
|
||||
|
||||
/** @var array<string, string|false> */
|
||||
private array $realpathCache = [];
|
||||
|
||||
public function __construct(string $projectRoot)
|
||||
{
|
||||
$real = @realpath($projectRoot);
|
||||
@ -82,37 +87,76 @@ final class Graph
|
||||
*/
|
||||
public function affected(array $changedFiles): array
|
||||
{
|
||||
$normalised = [];
|
||||
[$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles);
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
$unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet);
|
||||
|
||||
[$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]
|
||||
= $this->applyInertiaChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$this->applyTestFileChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$this->applyWatchPatternFallback(
|
||||
$nonMigrationPaths,
|
||||
$unparseableMigrations,
|
||||
$preciselyHandledPages,
|
||||
$sharedFilesResolved,
|
||||
$staticallyHandledBlade,
|
||||
$affectedSet,
|
||||
);
|
||||
|
||||
$this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet);
|
||||
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $changedFiles
|
||||
* @return array{0: list<string>, 1: list<string>}
|
||||
*/
|
||||
private function partitionChangedPaths(array $changedFiles): array
|
||||
{
|
||||
$migrations = [];
|
||||
$nonMigrations = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$normalised[] = $rel;
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
$migrationPaths = [];
|
||||
$nonMigrationPaths = [];
|
||||
|
||||
foreach ($normalised as $rel) {
|
||||
if ($this->isMigrationPath($rel)) {
|
||||
$migrationPaths[] = $rel;
|
||||
$migrations[] = $rel;
|
||||
} else {
|
||||
$nonMigrationPaths[] = $rel;
|
||||
$nonMigrations[] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
return [$migrations, $nonMigrations];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $migrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return list<string> Unparseable migrations (caller treats as unknown-to-graph).
|
||||
*/
|
||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$changedTables = [];
|
||||
$unparseableMigrations = [];
|
||||
$unparseable = [];
|
||||
|
||||
foreach ($migrationPaths as $rel) {
|
||||
$tables = $this->tablesForMigration($rel);
|
||||
|
||||
if ($tables === []) {
|
||||
$unparseableMigrations[] = $rel;
|
||||
$unparseable[] = $rel;
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -138,6 +182,17 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
return $unparseable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
|
||||
* globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved
|
||||
*/
|
||||
private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$globalFrontendRuntimeFiles = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
@ -169,6 +224,7 @@ final class Graph
|
||||
}
|
||||
|
||||
$sharedFilesResolved = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
continue;
|
||||
@ -176,12 +232,12 @@ final class Graph
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($this->jsFileToComponents[$rel])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$touchedAny = false;
|
||||
|
||||
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
|
||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||
$changedComponents[$pageComponent] = true;
|
||||
@ -195,6 +251,7 @@ final class Graph
|
||||
}
|
||||
|
||||
$newJsFiles = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
continue;
|
||||
@ -215,39 +272,7 @@ final class Graph
|
||||
}
|
||||
|
||||
if ($newJsFiles !== []) {
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
View::render('components.badge', [
|
||||
'type' => 'WARN',
|
||||
'content' => sprintf(
|
||||
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
count($newJsFiles),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
foreach ($newJsFiles as $rel) {
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$touchedAny = false;
|
||||
foreach ($pages as $pageComponent) {
|
||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||
$changedComponents[$pageComponent] = true;
|
||||
$touchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($touchedAny) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
|
||||
}
|
||||
|
||||
if ($changedComponents !== []) {
|
||||
@ -266,6 +291,61 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $newJsFiles
|
||||
* @param array<string, true> $changedComponents
|
||||
* @param array<string, true> $sharedFilesResolved
|
||||
*/
|
||||
private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void
|
||||
{
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
View::render('components.badge', [
|
||||
'type' => 'WARN',
|
||||
'content' => sprintf(
|
||||
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
count($newJsFiles),
|
||||
),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($newJsFiles as $rel) {
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$touchedAny = false;
|
||||
|
||||
foreach ($pages as $pageComponent) {
|
||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||
$changedComponents[$pageComponent] = true;
|
||||
$touchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($touchedAny) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return array<string, true> Unknown source dirs (sibling-heuristic).
|
||||
*/
|
||||
private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$changedIds = [];
|
||||
$unknownSourceDirs = [];
|
||||
$sourcePhpChanged = false;
|
||||
@ -282,9 +362,7 @@ final class Graph
|
||||
}
|
||||
|
||||
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
||||
$absolute = $this->projectRoot.'/'.$rel;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -316,8 +394,18 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// A changed file inside the configured test suites is itself the unit
|
||||
// of work — always run it (new untracked tests, edited tests, renames).
|
||||
return $unknownSourceDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A changed file inside the configured test suites is itself the unit of
|
||||
* work — always run it (new untracked tests, edited tests, renames).
|
||||
*
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
*/
|
||||
private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void
|
||||
{
|
||||
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
@ -332,9 +420,19 @@ final class Graph
|
||||
}
|
||||
$affectedSet[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered.
|
||||
*
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$staticallyHandled = [];
|
||||
|
||||
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
|
||||
$staticallyHandledBlade = [];
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($this->fileIds[$rel])) {
|
||||
continue;
|
||||
@ -353,13 +451,33 @@ final class Graph
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
$staticallyHandled[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
$staticallyHandled[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $staticallyHandled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param list<string> $unparseableMigrations
|
||||
* @param array<string, true> $preciselyHandledPages
|
||||
* @param array<string, true> $sharedFilesResolved
|
||||
* @param array<string, true> $staticallyHandledBlade
|
||||
* @param array<string, true> $affectedSet
|
||||
*/
|
||||
private function applyWatchPatternFallback(
|
||||
array $nonMigrationPaths,
|
||||
array $unparseableMigrations,
|
||||
array $preciselyHandledPages,
|
||||
array $sharedFilesResolved,
|
||||
array $staticallyHandledBlade,
|
||||
array &$affectedSet,
|
||||
): void {
|
||||
$unknownToGraph = $unparseableMigrations;
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
continue;
|
||||
@ -388,30 +506,37 @@ final class Graph
|
||||
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unknownSourceDirs !== []) {
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
if (isset($affectedSet[$testFile])) {
|
||||
/**
|
||||
* @param array<string, true> $unknownSourceDirs
|
||||
* @param array<string, true> $affectedSet
|
||||
*/
|
||||
private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void
|
||||
{
|
||||
if ($unknownSourceDirs === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
if (isset($affectedSet[$testFile])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (! isset($this->files[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (! isset($this->files[$id])) {
|
||||
continue;
|
||||
}
|
||||
$depDir = dirname($this->files[$id]);
|
||||
|
||||
$depDir = dirname($this->files[$id]);
|
||||
if (isset($unknownSourceDirs[$depDir])) {
|
||||
$affectedSet[$testFile] = true;
|
||||
|
||||
if (isset($unknownSourceDirs[$depDir])) {
|
||||
$affectedSet[$testFile] = true;
|
||||
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
public function knowsTest(string $testFile): bool
|
||||
@ -522,7 +647,7 @@ final class Graph
|
||||
$files = [];
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
if (! self::shouldRerun($result['status'])) {
|
||||
if (! $this->shouldRerun($result['status'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -549,7 +674,7 @@ final class Graph
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
if (! self::shouldRerun($result['status'])) {
|
||||
if (! $this->shouldRerun($result['status'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -563,14 +688,61 @@ final class Graph
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function shouldRerun(int $status): bool
|
||||
private function shouldRerun(int $status): bool
|
||||
{
|
||||
$testStatus = TestStatus::from($status);
|
||||
|
||||
return $testStatus->isFailure()
|
||||
|| $testStatus->isError()
|
||||
|| $testStatus->isIncomplete()
|
||||
|| $testStatus->isRisky();
|
||||
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$configuration = Registry::get();
|
||||
|
||||
if ($testStatus->isRisky()) {
|
||||
return $configuration->failOnRisky();
|
||||
}
|
||||
|
||||
if ($testStatus->isWarning()) {
|
||||
if ($configuration->failOnWarning()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnTestsThatTriggerWarnings();
|
||||
}
|
||||
|
||||
if ($testStatus->isNotice()) {
|
||||
if ($configuration->failOnNotice()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnTestsThatTriggerNotices();
|
||||
}
|
||||
|
||||
if ($testStatus->isDeprecation()) {
|
||||
if ($configuration->failOnDeprecation()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
|
||||
}
|
||||
|
||||
if ($testStatus->isIncomplete()) {
|
||||
if ($configuration->failOnIncomplete()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnIncompleteTests();
|
||||
}
|
||||
|
||||
if ($testStatus->isSkipped()) {
|
||||
if ($configuration->failOnSkipped()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnSkippedTests();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -832,29 +1004,16 @@ final class Graph
|
||||
return $this->archTestFiles;
|
||||
}
|
||||
|
||||
private function methodHasGroup(object $method, string $group): bool
|
||||
private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
|
||||
{
|
||||
if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) {
|
||||
if (in_array($group, $method->groups, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($method->attributes as $attribute) {
|
||||
if (! is_object($attribute)) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'name')) {
|
||||
continue;
|
||||
}
|
||||
if ($attribute->name !== Group::class) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'arguments')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($attribute->arguments as $argument) {
|
||||
if ($argument === $group) {
|
||||
@ -988,9 +1147,8 @@ final class Graph
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file instanceof \SplFileInfo) {
|
||||
continue;
|
||||
}
|
||||
assert($file instanceof \SplFileInfo);
|
||||
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
@ -1163,6 +1321,51 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune baseline result entries whose test files were just executed but whose
|
||||
* test IDs are no longer present (e.g. the test method was removed or renamed).
|
||||
*
|
||||
* @param array<int, string> $touchedFiles Absolute or project-relative paths.
|
||||
* @param array<int, string> $keepTestIds Test IDs that produced a result this run.
|
||||
*/
|
||||
public function pruneStaleResults(string $branch, array $touchedFiles, array $keepTestIds): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch]['results'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$touched = [];
|
||||
foreach ($touchedFiles as $file) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$touched[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($touched === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keep = array_fill_keys($keepTestIds, true);
|
||||
|
||||
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($touched[$file])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($keep[$testId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->baselines[$branch]['results'][$testId]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function decode(string $json, string $projectRoot): ?self
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
@ -1178,78 +1381,51 @@ final class Graph
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
|
||||
if (isset($data['test_tables']) && is_array($data['test_tables'])) {
|
||||
foreach ($data['test_tables'] as $testRel => $tables) {
|
||||
if (! is_string($testRel)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($tables)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (is_string($table) && $table !== '') {
|
||||
$names[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$graph->testTables[$testRel] = $names;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
|
||||
foreach ($data['test_inertia_components'] as $testRel => $components) {
|
||||
if (! is_string($testRel)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (is_string($component) && $component !== '') {
|
||||
$names[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$graph->testInertiaComponents[$testRel] = $names;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
|
||||
foreach ($data['js_file_to_components'] as $path => $components) {
|
||||
if (! is_string($path)) {
|
||||
continue;
|
||||
}
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (is_string($component) && $component !== '') {
|
||||
$names[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$graph->jsFileToComponents[$path] = $names;
|
||||
}
|
||||
}
|
||||
}
|
||||
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
||||
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
||||
$graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null);
|
||||
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private static function decodeStringMap(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($section as $key => $values) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && $value !== '') {
|
||||
$names[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$out[$key] = $names;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function encode(): ?string
|
||||
{
|
||||
$payload = [
|
||||
@ -1283,7 +1459,11 @@ final class Graph
|
||||
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||
|| (strlen($path) >= 2 && $path[1] === ':');
|
||||
if ($isAbsolute) {
|
||||
$real = @realpath($path);
|
||||
if (array_key_exists($path, $this->realpathCache)) {
|
||||
$real = $this->realpathCache[$path];
|
||||
} else {
|
||||
$real = $this->realpathCache[$path] = @realpath($path);
|
||||
}
|
||||
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class JsImportParser
|
||||
{
|
||||
private const array PAGE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte'];
|
||||
|
||||
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
|
||||
|
||||
private const string JS_DIR = 'resources/js';
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public static function parse(string $projectRoot): array
|
||||
{
|
||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
|
||||
$pagesRoot = null;
|
||||
|
||||
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
|
||||
if (is_dir($abs)) {
|
||||
$pagesRoot = $abs;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pagesRoot === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$reverse = [];
|
||||
|
||||
foreach (self::collectPages($pagesRoot) as $pageAbs) {
|
||||
$component = self::componentName($pagesRoot, $pageAbs);
|
||||
|
||||
if ($component === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$visited = [];
|
||||
self::collectTransitive($pageAbs, $projectRoot, $jsRoot, $visited);
|
||||
|
||||
foreach (array_keys($visited) as $depAbs) {
|
||||
if ($depAbs === $pageAbs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($depAbs, strlen($projectRoot) + 1));
|
||||
$reverse[$rel][$component] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($reverse as $path => $components) {
|
||||
$names = array_keys($components);
|
||||
sort($names);
|
||||
$out[$path] = $names;
|
||||
}
|
||||
|
||||
ksort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function collectPages(string $pagesRoot): array
|
||||
{
|
||||
$out = [];
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
foreach ($iterator as $fileInfo) {
|
||||
if (! $fileInfo->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = strtolower((string) $fileInfo->getExtension());
|
||||
if (in_array($ext, self::PAGE_EXTENSIONS, true)) {
|
||||
$out[] = $fileInfo->getPathname();
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function componentName(string $pagesRoot, string $pageAbs): ?string
|
||||
{
|
||||
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($pageAbs, strlen($pagesRoot) + 1));
|
||||
$dot = strrpos($rel, '.');
|
||||
|
||||
if ($dot === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = substr($rel, 0, $dot);
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $visited
|
||||
*/
|
||||
private static function collectTransitive(string $fileAbs, string $projectRoot, string $jsRoot, array &$visited): void
|
||||
{
|
||||
if (isset($visited[$fileAbs])) {
|
||||
return;
|
||||
}
|
||||
$visited[$fileAbs] = true;
|
||||
|
||||
$source = self::loadSource($fileAbs);
|
||||
if ($source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (self::extractImports($source) as $spec) {
|
||||
$resolved = self::resolveImport($spec, $fileAbs, $jsRoot);
|
||||
if ($resolved === null) {
|
||||
continue;
|
||||
}
|
||||
if (! is_file($resolved)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::collectTransitive($resolved, $projectRoot, $jsRoot, $visited);
|
||||
}
|
||||
}
|
||||
|
||||
private static function loadSource(string $fileAbs): ?string
|
||||
{
|
||||
$content = @file_get_contents($fileAbs);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_ends_with(strtolower($fileAbs), '.vue')) {
|
||||
$scripts = [];
|
||||
if (preg_match_all('/<script[^>]*>(.*?)<\/script>/si', $content, $m) !== false) {
|
||||
foreach ($m[1] as $block) {
|
||||
$scripts[] = $block;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $scripts);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function extractImports(string $source): array
|
||||
{
|
||||
$stripped = preg_replace('#//[^\n]*#', '', $source) ?? $source;
|
||||
$stripped = preg_replace('#/\*.*?\*/#s', '', $stripped) ?? $stripped;
|
||||
|
||||
$specs = [];
|
||||
|
||||
if (preg_match_all('/\bimport\s+(?:[^\'"()]*?\s+from\s+)?[\'"]([^\'"]+)[\'"]/', $stripped, $matches) !== false) {
|
||||
foreach ($matches[1] as $spec) {
|
||||
$specs[] = $spec;
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/\bimport\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $stripped, $matches) !== false) {
|
||||
foreach ($matches[1] as $spec) {
|
||||
$specs[] = $spec;
|
||||
}
|
||||
}
|
||||
|
||||
return $specs;
|
||||
}
|
||||
|
||||
private static function resolveImport(string $spec, string $importerAbs, string $jsRoot): ?string
|
||||
{
|
||||
if ($spec === '' || $spec[0] === '.' || $spec[0] === '/') {
|
||||
return self::resolveRelative($spec, $importerAbs);
|
||||
}
|
||||
|
||||
if (str_starts_with($spec, '@/') || str_starts_with($spec, '~/')) {
|
||||
$tail = substr($spec, 2);
|
||||
|
||||
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function resolveRelative(string $spec, string $importerAbs): ?string
|
||||
{
|
||||
if ($spec === '' || $spec[0] === '/') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = dirname($importerAbs);
|
||||
$path = $base.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $spec);
|
||||
|
||||
return self::withExtension($path);
|
||||
}
|
||||
|
||||
private static function withExtension(string $path): ?string
|
||||
{
|
||||
if (is_file($path)) {
|
||||
return realpath($path) ?: $path;
|
||||
}
|
||||
|
||||
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
|
||||
$candidate = $path.'.'.$ext;
|
||||
if (is_file($candidate)) {
|
||||
return realpath($candidate) ?: $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
|
||||
$candidate = $path.DIRECTORY_SEPARATOR.'index.'.$ext;
|
||||
if (is_file($candidate)) {
|
||||
return realpath($candidate) ?: $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,42 @@ final class JsModuleGraph
|
||||
|
||||
private const string CACHE_FILE = 'js-module-graph.cache.json';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const array VITE_CONFIG_NAMES = [
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
];
|
||||
|
||||
/**
|
||||
* Candidate page directories, in priority order. Must stay in sync with
|
||||
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array PAGE_DIR_CANDIDATES = [
|
||||
'resources/js/Pages',
|
||||
'resources/js/pages',
|
||||
'assets/js/Pages',
|
||||
'assets/js/pages',
|
||||
'assets/Pages',
|
||||
'assets/pages',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array PAGE_EXTENSIONS = [
|
||||
'vue', 'svelte',
|
||||
'tsx', 'jsx',
|
||||
'ts', 'js',
|
||||
'mts', 'cts', 'mjs', 'cjs',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
@ -40,8 +76,44 @@ final class JsModuleGraph
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['Pages', 'pages'] as $dir) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
return self::firstExistingPagesDir($projectRoot) !== null;
|
||||
}
|
||||
|
||||
private static function firstExistingPagesDir(string $projectRoot): ?string
|
||||
{
|
||||
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
|
||||
if (! is_dir($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::dirHasPageFile($abs)) {
|
||||
return $abs;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function dirHasPageFile(string $dir): bool
|
||||
{
|
||||
try {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
);
|
||||
} catch (\UnexpectedValueException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -155,13 +227,9 @@ final class JsModuleGraph
|
||||
|
||||
private static function fingerprint(string $projectRoot): ?string
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (! is_file($path)) {
|
||||
@ -177,17 +245,25 @@ final class JsModuleGraph
|
||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
||||
}
|
||||
|
||||
foreach (['Pages', 'pages'] as $dir) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
$parts[] = 'pagesDir:'.$dir;
|
||||
|
||||
break;
|
||||
}
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
|
||||
$override = getenv('TIA_VITE_PAGES_DIR');
|
||||
|
||||
if (is_dir($jsRoot)) {
|
||||
if (is_string($override) && $override !== '') {
|
||||
$parts[] = 'pagesDirOverride:'.$override;
|
||||
}
|
||||
|
||||
$pagesDir = self::firstExistingPagesDir($projectRoot);
|
||||
|
||||
if ($pagesDir !== null) {
|
||||
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
|
||||
}
|
||||
|
||||
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
|
||||
|
||||
if ($jsRoot !== null && is_dir($jsRoot)) {
|
||||
$entries = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
@ -310,7 +386,7 @@ final class JsModuleGraph
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
||||
use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
@ -33,21 +32,6 @@ final class Recorder
|
||||
/** @var array<string, bool> */
|
||||
private array $classUsesDatabaseCache = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $fileToClassNames = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $indexedClassNames = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $classDependencyCache = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $testImportFileCache = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $includedFilesAtTestStart = [];
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
@ -89,13 +73,6 @@ final class Recorder
|
||||
return $this->driverAvailable;
|
||||
}
|
||||
|
||||
public function driver(): string
|
||||
{
|
||||
$this->driverAvailable();
|
||||
|
||||
return $this->driver;
|
||||
}
|
||||
|
||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable()) {
|
||||
@ -113,15 +90,11 @@ final class Recorder
|
||||
}
|
||||
|
||||
$this->currentTestFile = $file;
|
||||
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
|
||||
|
||||
if ($this->classUsesDatabase($className)) {
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
// $this->linkAncestorFiles($className);
|
||||
// $this->linkImportedFiles($file);
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
\pcov\start();
|
||||
@ -166,19 +139,7 @@ final class Recorder
|
||||
$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;
|
||||
}
|
||||
|
||||
// $this->linkSourceDependencies($coveredFiles);
|
||||
|
||||
$this->currentTestFile = null;
|
||||
$this->includedFilesAtTestStart = [];
|
||||
}
|
||||
|
||||
public function linkSource(string $sourceFile): void
|
||||
@ -198,295 +159,6 @@ final class Recorder
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
/** @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;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, string> $coveredFiles */
|
||||
private function linkSourceDependencies(array $coveredFiles): void
|
||||
{
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshClassMap();
|
||||
|
||||
foreach ($coveredFiles as $coveredFile) {
|
||||
if (! isset($this->fileToClassNames[$coveredFile])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->fileToClassNames[$coveredFile] as $name) {
|
||||
foreach ($this->classDependencies($name) as $depFile) {
|
||||
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function refreshClassMap(): void
|
||||
{
|
||||
$names = array_merge(
|
||||
get_declared_classes(),
|
||||
get_declared_interfaces(),
|
||||
get_declared_traits(),
|
||||
);
|
||||
|
||||
foreach ($names as $name) {
|
||||
if (isset($this->indexedClassNames[$name])) {
|
||||
continue;
|
||||
}
|
||||
$this->indexedClassNames[$name] = true;
|
||||
|
||||
if (! class_exists($name, false)
|
||||
&& ! interface_exists($name, false)
|
||||
&& ! trait_exists($name, false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($name);
|
||||
|
||||
if ($reflection->isInternal()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $reflection->getFileName();
|
||||
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->fileToClassNames[$file][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function classDependencies(string $className): array
|
||||
{
|
||||
if (isset($this->classDependencyCache[$className])) {
|
||||
return $this->classDependencyCache[$className];
|
||||
}
|
||||
|
||||
if (! class_exists($className, false)
|
||||
&& ! interface_exists($className, false)
|
||||
&& ! trait_exists($className, false)) {
|
||||
return $this->classDependencyCache[$className] = [];
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
$files = [];
|
||||
|
||||
$linkSymbol = static function (string $name) use (&$files): void {
|
||||
if (! class_exists($name, false)
|
||||
&& ! interface_exists($name, false)
|
||||
&& ! trait_exists($name, false)) {
|
||||
return;
|
||||
}
|
||||
$r = new ReflectionClass($name);
|
||||
if ($r->isInternal()) {
|
||||
return;
|
||||
}
|
||||
$f = $r->getFileName();
|
||||
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
return;
|
||||
}
|
||||
$files[$f] = true;
|
||||
};
|
||||
|
||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
||||
$linkSymbol($iname);
|
||||
}
|
||||
|
||||
foreach ($reflection->getTraitNames() as $tname) {
|
||||
$linkSymbol($tname);
|
||||
}
|
||||
|
||||
$parent = $reflection->getParentClass();
|
||||
while ($parent !== false && ! $parent->isInternal()) {
|
||||
$f = $parent->getFileName();
|
||||
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
$files[$f] = true;
|
||||
}
|
||||
foreach ($parent->getTraitNames() as $tname) {
|
||||
$linkSymbol($tname);
|
||||
}
|
||||
$parent = $parent->getParentClass();
|
||||
}
|
||||
|
||||
return $this->classDependencyCache[$className] = array_keys($files);
|
||||
}
|
||||
|
||||
private function linkAncestorFiles(string $className): void
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
$parent = $reflection->getParentClass();
|
||||
|
||||
while ($parent !== false) {
|
||||
if ($parent->isInternal()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$file = $parent->getFileName();
|
||||
|
||||
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
|
||||
}
|
||||
|
||||
$parent = $parent->getParentClass();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
if (! 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;
|
||||
}
|
||||
|
||||
private function classUsesDatabase(string $className): bool
|
||||
{
|
||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
||||
@ -624,23 +296,9 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
$property = $reflection->getProperty('__filename');
|
||||
|
||||
if ($property->isStatic()) {
|
||||
$value = $property->getValue();
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$file = $reflection->getFileName();
|
||||
|
||||
return is_string($file) ? $file : null;
|
||||
return $className::$__filename;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -691,9 +349,6 @@ final class Recorder
|
||||
$this->perTestUsesDatabase = [];
|
||||
$this->classFileCache = [];
|
||||
$this->classUsesDatabaseCache = [];
|
||||
$this->fileToClassNames = [];
|
||||
$this->indexedClassNames = [];
|
||||
$this->classDependencyCache = [];
|
||||
$this->sourceScope = null;
|
||||
$this->active = false;
|
||||
}
|
||||
|
||||
@ -4,11 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\TextUI\Configuration\Registry;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class SourceScope
|
||||
final class SourceScope
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $containsCache = [];
|
||||
|
||||
private const array TOP_LEVEL_NOISE = [
|
||||
'vendor',
|
||||
'node_modules',
|
||||
@ -32,25 +38,27 @@ final readonly class SourceScope
|
||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
||||
*/
|
||||
public function __construct(
|
||||
private array $includes,
|
||||
private array $excludes,
|
||||
private readonly array $includes,
|
||||
private readonly array $excludes,
|
||||
) {}
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
{
|
||||
$configPath = self::configPath($projectRoot);
|
||||
|
||||
$phpunitIncludes = [];
|
||||
$phpunitExcludes = [];
|
||||
|
||||
if ($configPath !== null) {
|
||||
$xml = @simplexml_load_file($configPath);
|
||||
try {
|
||||
$source = Registry::get()->source();
|
||||
|
||||
if ($xml !== false) {
|
||||
$configDir = dirname($configPath);
|
||||
$phpunitIncludes = self::extractDirectories($xml, 'source/include/directory', $configDir);
|
||||
$phpunitExcludes = self::extractDirectories($xml, 'source/exclude/directory', $configDir);
|
||||
foreach ($source->includeDirectories() as $dir) {
|
||||
$phpunitIncludes[] = self::normalise($dir->path());
|
||||
}
|
||||
|
||||
foreach ($source->excludeDirectories() as $dir) {
|
||||
$phpunitExcludes[] = self::normalise($dir->path());
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Registry not initialized — fall back to project-root scanning.
|
||||
}
|
||||
|
||||
$rootIncludes = self::topLevelProjectDirs($projectRoot);
|
||||
@ -69,96 +77,52 @@ final readonly class SourceScope
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
||||
*/
|
||||
public static function testPaths(string $projectRoot): array
|
||||
public static function testPaths(): array
|
||||
{
|
||||
$configPath = self::configPath($projectRoot);
|
||||
|
||||
if ($configPath === null) {
|
||||
try {
|
||||
$suites = Registry::get()->testSuite();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($suites as $suite) {
|
||||
foreach ($suite->directories() as $directory) {
|
||||
$out[] = self::normalise($directory->path());
|
||||
}
|
||||
|
||||
$xml = @simplexml_load_file($configPath);
|
||||
|
||||
if ($xml === false) {
|
||||
return [];
|
||||
foreach ($suite->files() as $file) {
|
||||
$out[] = self::normalise($file->path());
|
||||
}
|
||||
}
|
||||
|
||||
$configDir = dirname($configPath);
|
||||
|
||||
return array_values(array_unique([
|
||||
...self::extractDirectories($xml, 'testsuites/testsuite/directory', $configDir),
|
||||
...self::extractDirectories($xml, 'testsuites/testsuite/file', $configDir),
|
||||
]));
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
public function contains(string $absoluteFile): bool
|
||||
{
|
||||
if (isset($this->containsCache[$absoluteFile])) {
|
||||
return $this->containsCache[$absoluteFile];
|
||||
}
|
||||
|
||||
$real = @realpath($absoluteFile);
|
||||
$candidate = $real === false ? $absoluteFile : $real;
|
||||
$candidate = self::normalise($candidate);
|
||||
|
||||
foreach ($this->excludes as $excluded) {
|
||||
if ($this->startsWithDir($candidate, $excluded)) {
|
||||
return false;
|
||||
return $this->containsCache[$absoluteFile] = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->includes as $included) {
|
||||
if ($this->startsWithDir($candidate, $included)) {
|
||||
return true;
|
||||
return $this->containsCache[$absoluteFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function includes(): array
|
||||
{
|
||||
return $this->includes;
|
||||
}
|
||||
|
||||
private static function configPath(string $projectRoot): ?string
|
||||
{
|
||||
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
|
||||
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function extractDirectories(\SimpleXMLElement $xml, string $xpath, string $configDir): array
|
||||
{
|
||||
$nodes = $xml->xpath($xpath);
|
||||
|
||||
if (! is_array($nodes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$value = trim((string) $node);
|
||||
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = self::resolveRelative($value, $configDir);
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
return $this->containsCache[$absoluteFile] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -216,22 +180,6 @@ final readonly class SourceScope
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function resolveRelative(string $path, string $configDir): string
|
||||
{
|
||||
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'));
|
||||
|
||||
$combined = $isAbsolute ? $path : $configDir.DIRECTORY_SEPARATOR.$path;
|
||||
|
||||
$real = @realpath($combined);
|
||||
|
||||
if ($real === false) {
|
||||
return self::normalise($combined);
|
||||
}
|
||||
|
||||
return self::normalise($real);
|
||||
}
|
||||
|
||||
private static function normalise(string $path): string
|
||||
{
|
||||
return rtrim($path, '/\\');
|
||||
|
||||
@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\TextUI\Configuration\Registry;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Resolves the set of project-relative paths that are considered test files,
|
||||
@ -17,8 +19,8 @@ final readonly class TestPaths
|
||||
{
|
||||
/**
|
||||
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
|
||||
* @param list<string> $files Project-relative file paths.
|
||||
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
|
||||
* @param list<string> $files Project-relative file paths.
|
||||
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
|
||||
*/
|
||||
public function __construct(
|
||||
private array $directories,
|
||||
@ -28,39 +30,48 @@ final readonly class TestPaths
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
{
|
||||
$configPath = self::configPath($projectRoot);
|
||||
|
||||
$directories = [];
|
||||
$files = [];
|
||||
$suffixes = ['.php'];
|
||||
$suffixes = [];
|
||||
|
||||
if ($configPath !== null) {
|
||||
$xml = @simplexml_load_file($configPath);
|
||||
try {
|
||||
$configuration = Registry::get();
|
||||
|
||||
if ($xml !== false) {
|
||||
$configDir = dirname($configPath);
|
||||
|
||||
foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) {
|
||||
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
|
||||
foreach ($configuration->testSuite() as $suite) {
|
||||
foreach ($suite->directories() as $directory) {
|
||||
$rel = self::toRelative($directory->path(), $projectRoot);
|
||||
|
||||
if ($rel !== null) {
|
||||
$directories[] = $rel;
|
||||
}
|
||||
|
||||
$suffix = (string) ($node['suffix'] ?? '');
|
||||
if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) {
|
||||
$suffix = $directory->suffix();
|
||||
|
||||
if ($suffix !== '') {
|
||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) {
|
||||
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
|
||||
foreach ($suite->files() as $file) {
|
||||
$rel = self::toRelative($file->path(), $projectRoot);
|
||||
|
||||
if ($rel !== null) {
|
||||
$files[] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($suffixes === []) {
|
||||
foreach ($configuration->testSuffixes() as $suffix) {
|
||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Registry not initialized — fall through to defaults.
|
||||
}
|
||||
|
||||
if ($suffixes === []) {
|
||||
$suffixes = ['.php'];
|
||||
}
|
||||
|
||||
if ($directories === [] && $files === []) {
|
||||
@ -109,20 +120,7 @@ final readonly class TestPaths
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function configPath(string $projectRoot): ?string
|
||||
{
|
||||
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
|
||||
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function toRelative(string $value, string $configDir, string $projectRoot): ?string
|
||||
private static function toRelative(string $value, string $projectRoot): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
@ -130,13 +128,8 @@ final readonly class TestPaths
|
||||
return null;
|
||||
}
|
||||
|
||||
$isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR
|
||||
|| (strlen($value) >= 2 && $value[1] === ':');
|
||||
|
||||
$combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value;
|
||||
|
||||
$real = @realpath($combined);
|
||||
$resolved = $real === false ? $combined : $real;
|
||||
$real = @realpath($value);
|
||||
$resolved = $real === false ? $value : $real;
|
||||
|
||||
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
||||
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
||||
@ -152,7 +145,7 @@ final readonly class TestPaths
|
||||
{
|
||||
try {
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Browser\Support\BrowserTestIdentifier;
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
@ -25,29 +26,10 @@ final readonly class Browser implements WatchDefault
|
||||
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
|
||||
|
||||
$globs = [
|
||||
'resources/js/**/*.js',
|
||||
'resources/js/**/*.ts',
|
||||
'resources/js/**/*.tsx',
|
||||
'resources/js/**/*.jsx',
|
||||
'resources/js/**/*.vue',
|
||||
'resources/js/**/*.svelte',
|
||||
'resources/css/**/*.css',
|
||||
'resources/css/**/*.scss',
|
||||
'resources/css/**/*.less',
|
||||
'public/build/**/*.js',
|
||||
'public/build/**/*.css',
|
||||
'public/**/*.js',
|
||||
'public/**/*.css',
|
||||
'public/**/*.svg',
|
||||
'public/**/*.png',
|
||||
'public/**/*.jpg',
|
||||
'public/**/*.jpeg',
|
||||
'public/**/*.webp',
|
||||
'public/**/*.ico',
|
||||
'public/**/*.txt',
|
||||
'public/**/*.json',
|
||||
'public/**/*.xml',
|
||||
'public/hot',
|
||||
'resources/js/** !*.php',
|
||||
'resources/css/** !*.php',
|
||||
'public/hot !*.php',
|
||||
'public/** !*.php',
|
||||
];
|
||||
|
||||
$patterns = [];
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -20,27 +21,8 @@ final readonly class Inertia implements WatchDefault
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
|
||||
|
||||
$patterns = [];
|
||||
|
||||
foreach (['Pages', 'pages'] as $pages) {
|
||||
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
|
||||
$patterns["resources/js/{$pages}/**/*.{$ext}"] = $browserTargets;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
|
||||
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
|
||||
$patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
|
||||
}
|
||||
}
|
||||
|
||||
$patterns['resources/js/ssr.js'] = $browserTargets;
|
||||
$patterns['resources/js/ssr.ts'] = $browserTargets;
|
||||
$patterns['resources/js/app.js'] = $browserTargets;
|
||||
$patterns['resources/js/app.ts'] = $browserTargets;
|
||||
|
||||
return $patterns;
|
||||
return [
|
||||
'resources/js/** !*.php' => [$testPath],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -20,46 +21,21 @@ final readonly class Laravel implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
'config/*.php' => [$testPath],
|
||||
'config/**/*.php' => [$testPath],
|
||||
|
||||
'routes/*.php' => [$testPath],
|
||||
'routes/**/*.php' => [$testPath],
|
||||
|
||||
'bootstrap/app.php' => [$testPath],
|
||||
'bootstrap/providers.php' => [$testPath],
|
||||
|
||||
'database/migrations/**/*.php' => [$testPath],
|
||||
|
||||
'database/seeders/**/*.php' => [$testPath],
|
||||
|
||||
'database/factories/**/*.php' => [$testPath],
|
||||
|
||||
'storage/fixtures/**/*' => [$testPath],
|
||||
|
||||
'app/**/*.tpl' => [$testPath],
|
||||
'app/**/*.stub' => [$testPath],
|
||||
'app/**/*.json' => [$testPath],
|
||||
'app/**/*.yaml' => [$testPath],
|
||||
'app/**/*.yml' => [$testPath],
|
||||
'app/**/*.txt' => [$testPath],
|
||||
'app/** !*.php' => [$testPath],
|
||||
|
||||
'resources/views/**/*.blade.php' => [$testPath],
|
||||
'resources/views/**/*.css' => [$testPath],
|
||||
'resources/views/email/**/*.blade.php' => [$testPath],
|
||||
'resources/views/emails/**/*.blade.php' => [$testPath],
|
||||
'resources/views/**' => [$testPath],
|
||||
|
||||
'lang/**/*.php' => [$testPath],
|
||||
'lang/**/*.json' => [$testPath],
|
||||
'resources/lang/**/*.php' => [$testPath],
|
||||
'resources/lang/**/*.json' => [$testPath],
|
||||
'lang/**' => [$testPath],
|
||||
'resources/lang/**' => [$testPath],
|
||||
|
||||
'vite.config.js' => [$testPath],
|
||||
'vite.config.ts' => [$testPath],
|
||||
'webpack.mix.js' => [$testPath],
|
||||
'tailwind.config.js' => [$testPath],
|
||||
'tailwind.config.ts' => [$testPath],
|
||||
'postcss.config.js' => [$testPath],
|
||||
'vite.config.* !*.php' => [$testPath],
|
||||
'webpack.mix.* !*.php' => [$testPath],
|
||||
'tailwind.config.* !*.php' => [$testPath],
|
||||
'postcss.config.* !*.php' => [$testPath],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
||||
@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -25,11 +27,7 @@ final readonly class Php implements WatchDefault
|
||||
'docker-compose.yml' => [$testPath],
|
||||
'docker-compose.yaml' => [$testPath],
|
||||
|
||||
'phpunit.xml.dist' => [$testPath],
|
||||
|
||||
$testPath.'/Pest.php' => [$testPath],
|
||||
|
||||
$testPath.'/Datasets/**/*.php' => [$testPath],
|
||||
'phpunit.xml*' => [$testPath],
|
||||
|
||||
$testPath.'/Fixtures/**/*' => [$testPath],
|
||||
$testPath.'/**/Fixtures/**/*' => [$testPath],
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -20,43 +21,22 @@ final readonly class Symfony implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
'config/*.yaml' => [$testPath],
|
||||
'config/*.yml' => [$testPath],
|
||||
'config/*.php' => [$testPath],
|
||||
'config/*.xml' => [$testPath],
|
||||
'config/**/*.yaml' => [$testPath],
|
||||
'config/**/*.yml' => [$testPath],
|
||||
'config/**/*.php' => [$testPath],
|
||||
'config/**/*.xml' => [$testPath],
|
||||
|
||||
'config/routes/*.yaml' => [$testPath],
|
||||
'config/routes/*.php' => [$testPath],
|
||||
'config/routes/*.xml' => [$testPath],
|
||||
'config/routes/**/*.yaml' => [$testPath],
|
||||
|
||||
'src/Kernel.php' => [$testPath],
|
||||
'config/** !*.php' => [$testPath],
|
||||
'config/routes/** !*.php' => [$testPath],
|
||||
|
||||
'migrations/**/*.php' => [$testPath],
|
||||
'src/Migrations/**/*.php' => [$testPath],
|
||||
|
||||
'templates/**/*.html.twig' => [$testPath],
|
||||
'templates/**/*.twig' => [$testPath],
|
||||
'templates/** !*.php' => [$testPath],
|
||||
|
||||
'translations/**/*.yaml' => [$testPath],
|
||||
'translations/**/*.yml' => [$testPath],
|
||||
'translations/**/*.xlf' => [$testPath],
|
||||
'translations/**/*.xliff' => [$testPath],
|
||||
'translations/** !*.php' => [$testPath],
|
||||
|
||||
'config/doctrine/**/*.xml' => [$testPath],
|
||||
'config/doctrine/**/*.yaml' => [$testPath],
|
||||
|
||||
'webpack.config.js' => [$testPath],
|
||||
'importmap.php' => [$testPath],
|
||||
'assets/**/*.js' => [$testPath],
|
||||
'assets/**/*.ts' => [$testPath],
|
||||
'assets/**/*.vue' => [$testPath],
|
||||
'assets/**/*.css' => [$testPath],
|
||||
'assets/**/*.scss' => [$testPath],
|
||||
'assets/** !*.php' => [$testPath],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
@ -24,17 +24,26 @@ final class WatchPatterns
|
||||
WatchDefaults\Browser::class,
|
||||
];
|
||||
|
||||
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
|
||||
|
||||
/**
|
||||
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files
|
||||
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
|
||||
*/
|
||||
private array $patterns = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
|
||||
*/
|
||||
private array $parsed = [];
|
||||
|
||||
private bool $enabled = false;
|
||||
|
||||
private bool $locally = false;
|
||||
|
||||
private bool $filtered = false;
|
||||
|
||||
private bool $baselined = false;
|
||||
|
||||
public function useDefaults(string $projectRoot): void
|
||||
{
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
@ -46,22 +55,22 @@ final class WatchPatterns
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
|
||||
$this->patterns[$glob] = array_values(array_unique(
|
||||
array_merge($this->patterns[$glob] ?? [], $dirs),
|
||||
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
|
||||
$this->patterns[$key] = array_values(array_unique(
|
||||
array_merge($this->patterns[$key] ?? [], $dirs),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $patterns glob → project-relative test dir/file
|
||||
* @param array<string, string> $patterns pattern key → project-relative test dir/file
|
||||
*/
|
||||
public function add(array $patterns): void
|
||||
{
|
||||
foreach ($patterns as $glob => $dir) {
|
||||
$this->patterns[$glob] = array_values(array_unique(
|
||||
array_merge($this->patterns[$glob] ?? [], [$dir]),
|
||||
foreach ($patterns as $key => $dir) {
|
||||
$this->patterns[$key] = array_values(array_unique(
|
||||
array_merge($this->patterns[$key] ?? [], [$dir]),
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -80,11 +89,13 @@ final class WatchPatterns
|
||||
$matched = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
foreach ($this->patterns as $glob => $dirs) {
|
||||
if ($this->globMatches($glob, $file)) {
|
||||
foreach ($dirs as $dir) {
|
||||
$matched[$dir] = true;
|
||||
}
|
||||
foreach ($this->patterns as $key => $dirs) {
|
||||
if (! $this->keyMatches($key, $file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
$matched[$dir] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -156,12 +167,132 @@ final class WatchPatterns
|
||||
return $this->filtered;
|
||||
}
|
||||
|
||||
public function markBaselined(): void
|
||||
{
|
||||
$this->baselined = true;
|
||||
}
|
||||
|
||||
public function isBaselined(): bool
|
||||
{
|
||||
return $this->baselined;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->patterns = [];
|
||||
$this->parsed = [];
|
||||
$this->enabled = false;
|
||||
$this->locally = false;
|
||||
$this->filtered = false;
|
||||
$this->baselined = false;
|
||||
}
|
||||
|
||||
private function keyMatches(string $key, string $file): bool
|
||||
{
|
||||
$rule = $this->parse($key);
|
||||
|
||||
if (! $this->globMatches($rule['include'], $file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = str_replace('\\', '/', $file);
|
||||
|
||||
if ($this->touchesVcs($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($rule['excludes'] as $exclude) {
|
||||
if ($this->excludeMatches($exclude, $file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
|
||||
*/
|
||||
private function parse(string $key): array
|
||||
{
|
||||
if (isset($this->parsed[$key])) {
|
||||
return $this->parsed[$key];
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', trim($key)) ?: [];
|
||||
|
||||
$include = '';
|
||||
$excludes = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($token === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($token[0] === '!') {
|
||||
$excludes[] = substr($token, 1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($include === '') {
|
||||
$include = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parsed[$key] = [
|
||||
'include' => $include,
|
||||
'excludes' => $excludes,
|
||||
'allowDotfiles' => $this->patternTargetsDotfiles($include),
|
||||
];
|
||||
}
|
||||
|
||||
private function patternTargetsDotfiles(string $pattern): bool
|
||||
{
|
||||
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
|
||||
if ($segment !== '' && $segment[0] === '.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function touchesVcs(string $file): bool
|
||||
{
|
||||
foreach (explode('/', $file) as $segment) {
|
||||
if (in_array($segment, self::VCS_DIRS, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function touchesDotfile(string $file): bool
|
||||
{
|
||||
foreach (explode('/', $file) as $segment) {
|
||||
if ($segment !== '' && $segment[0] === '.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function excludeMatches(string $exclude, string $file): bool
|
||||
{
|
||||
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
|
||||
|
||||
if ($this->globMatches($pattern, $file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->globMatches($exclude, basename($file));
|
||||
}
|
||||
|
||||
private function globMatches(string $pattern, string $file): bool
|
||||
|
||||
@ -24,6 +24,9 @@ final class PcovRestarter implements Restarter
|
||||
}
|
||||
|
||||
if (getenv(self::ENV_RESTARTED) === '1') {
|
||||
putenv(self::ENV_RESTARTED);
|
||||
unset($_ENV[self::ENV_RESTARTED]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
|
||||
final readonly class EnsureTiaEnds implements FinishedSubscriber
|
||||
{
|
||||
public function __construct(private Recorder $recorder) {}
|
||||
|
||||
@ -4,14 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Concerns\Testable;
|
||||
use Pest\Exceptions\TiaRequiresPestTests;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -38,27 +36,10 @@ final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscrib
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->usesTestableTrait($className)) {
|
||||
if (method_exists($className, '__initializeTestCase')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panic::with(new TiaRequiresPestTests($className, $test->file()));
|
||||
}
|
||||
|
||||
private function usesTestableTrait(string $className): bool
|
||||
{
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
do {
|
||||
foreach ($reflection->getTraitNames() as $trait) {
|
||||
if ($trait === Testable::class) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$reflection = $reflection->getParentClass();
|
||||
} while ($reflection !== false);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,17 +6,17 @@ namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
use PHPUnit\Event\Test\PreparationStarted;
|
||||
use PHPUnit\Event\Test\PreparationStartedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
|
||||
final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(Prepared $event): void
|
||||
public function notify(PreparationStarted $event): void
|
||||
{
|
||||
$test = $event->test();
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
|
||||
final readonly class EnsureTiaStarts implements PreparedSubscriber
|
||||
{
|
||||
public function __construct(private Recorder $recorder) {}
|
||||
|
||||
@ -89,10 +89,6 @@ final class Coverage
|
||||
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
|
||||
}
|
||||
|
||||
// If TIA's marker is present, this run executed only the affected
|
||||
// tests. Merge their fresh coverage slice into the cached full-run
|
||||
// snapshot (stored by the previous `--tia --coverage` pass) so the
|
||||
// report reflects the entire suite, not just what re-ran.
|
||||
CoverageMerger::applyIfMarked($reportPath);
|
||||
|
||||
/** @var CodeCoverage $codeCoverage */
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support;
|
||||
|
||||
use Fidry\CpuCoreCounter\CpuCoreCounter;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Cpu
|
||||
{
|
||||
public static function cores(int $fallback = 4): int
|
||||
{
|
||||
return (new CpuCoreCounter)->getCountWithFallback($fallback);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
Pest Testing Framework 4.6.3.
|
||||
Pest Testing Framework 4.7.2.
|
||||
|
||||
USAGE: pest <file> [options]
|
||||
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
|
||||
Pest Testing Framework 4.6.3.
|
||||
Pest Testing Framework 4.7.2.
|
||||
|
||||
|
||||
@ -1,28 +1,56 @@
|
||||
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
|
||||
##teamcity[testCount count='8' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
|
||||
##teamcity[testCount count='8' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
|
||||
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
|
||||
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
|
||||
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
|
||||
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
|
||||
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
|
||||
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
|
||||
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
|
||||
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
|
||||
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
|
||||
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
|
||||
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
|
||||
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
|
||||
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
|
||||
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
|
||||
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
|
||||
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
|
||||
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
|
||||
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
|
||||
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
|
||||
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
|
||||
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
|
||||
|
||||
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
||||
[90mDuration:[39m [39m1.00s[39m
|
||||
|
||||
|
||||
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
||||
[90mDuration:[39m [39m1.00s[39m
|
||||
|
||||
|
||||
@ -1,19 +1,38 @@
|
||||
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
|
||||
##teamcity[testCount count='4' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
|
||||
##teamcity[testCount count='4' flowId='1234']
|
||||
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
|
||||
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
|
||||
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
|
||||
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
|
||||
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
|
||||
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
|
||||
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
|
||||
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
|
||||
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
|
||||
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
|
||||
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
|
||||
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
|
||||
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
|
||||
|
||||
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
||||
[90mDuration:[39m [39m1.00s[39m
|
||||
|
||||
|
||||
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
||||
[90mDuration:[39m [39m1.00s[39m
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
|
||||
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
|
||||
✓ globals
|
||||
✓ contracts
|
||||
|
||||
PASS Tests\Environments\Windows
|
||||
✓ global functions are loaded
|
||||
@ -1716,6 +1715,43 @@
|
||||
PASS Tests\Unit\Plugins\Retry
|
||||
✓ it orders by defects and stop on defects if when --retry is used
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\ContentHash
|
||||
✓ of() → it returns false when file does not exist
|
||||
✓ of() → it hashes an existing file
|
||||
✓ PHP files → it produces the same hash regardless of whitespace differences
|
||||
✓ PHP files → it ignores single-line comments
|
||||
✓ PHP files → it ignores hash-style comments
|
||||
✓ PHP files → it ignores multi-line comments
|
||||
✓ PHP files → it ignores doc comments
|
||||
✓ PHP files → it detects code changes
|
||||
✓ PHP files → it preserves whitespace inside string literals
|
||||
✓ PHP files → it treats variable renames as a change
|
||||
✓ PHP files → it falls back to a raw hash for unparseable PHP
|
||||
✓ PHP files → it is case-insensitive on the file extension
|
||||
✓ Blade files → it strips blade comments
|
||||
✓ Blade files → it strips multi-line blade comments
|
||||
✓ Blade files → it collapses whitespace
|
||||
✓ Blade files → it detects content changes
|
||||
✓ Blade files → it keeps blade directives intact
|
||||
✓ Blade files → it does not use the PHP tokenizer for blade files
|
||||
✓ JavaScript-like files → it strips line comments
|
||||
✓ JavaScript-like files → it strips block comments on their own lines
|
||||
✓ JavaScript-like files → it collapses whitespace
|
||||
✓ JavaScript-like files → it detects code changes
|
||||
✓ JavaScript-like files → it does not strip inline trailing comments
|
||||
✓ JavaScript-like files → it applies the same rules to .ts files
|
||||
✓ JavaScript-like files → it applies the same rules to .tsx files
|
||||
✓ JavaScript-like files → it applies the same rules to .jsx files
|
||||
✓ JavaScript-like files → it applies the same rules to .vue files
|
||||
✓ JavaScript-like files → it applies the same rules to .svelte files
|
||||
✓ JavaScript-like files → it applies the same rules to .mjs, .cjs, and .mts files
|
||||
✓ unknown extensions → it hashes the raw content for unknown extensions
|
||||
✓ unknown extensions → it does not normalise whitespace for unknown extensions
|
||||
✓ unknown extensions → it does not strip comments for unknown extensions
|
||||
✓ unknown extensions → it hashes files with no extension as raw content
|
||||
✓ output format → it returns a 32-character hex xxh128 hash
|
||||
✓ output format → it returns a stable hash for empty content
|
||||
|
||||
PASS Tests\Unit\Preset
|
||||
✓ preset invalid name
|
||||
✓ preset → myFramework
|
||||
@ -1901,4 +1937,4 @@
|
||||
✓ pass with dataset with ('my-datas-set-value')
|
||||
✓ within describe → pass with dataset with ('my-datas-set-value')
|
||||
|
||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1294 passed (2971 assertions)
|
||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1328 passed (3008 assertions)
|
||||
@ -33,13 +33,3 @@ arch('globals')
|
||||
->expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep'])
|
||||
->not->toBeUsed()
|
||||
->ignoring(Expectation::class);
|
||||
|
||||
arch('contracts')
|
||||
->expect('Pest\Contracts')
|
||||
->toOnlyUse([
|
||||
'NunoMaduro\Collision\Contracts',
|
||||
'Pest\Factories\TestCaseMethodFactory',
|
||||
'Symfony\Component\Console',
|
||||
'Pest\Arch\Contracts',
|
||||
'Pest\PendingCalls',
|
||||
])->toBeInterfaces();
|
||||
|
||||
261
tests/Unit/Plugins/Tia/ContentHash.php
Normal file
261
tests/Unit/Plugins/Tia/ContentHash.php
Normal file
@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
use Pest\Plugins\Tia\ContentHash;
|
||||
|
||||
describe('of()', function () {
|
||||
it('returns false when file does not exist', function () {
|
||||
expect(ContentHash::of('/path/that/does/not/exist.php'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hashes an existing file', function () {
|
||||
$path = tempnam(sys_get_temp_dir(), 'pest_').'.php';
|
||||
file_put_contents($path, "<?php echo 'hi';");
|
||||
|
||||
try {
|
||||
expect(ContentHash::of($path))->toBeString()->not->toBeEmpty();
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHP files', function () {
|
||||
it('produces the same hash regardless of whitespace differences', function () {
|
||||
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;\n\necho \$foo;");
|
||||
$b = ContentHash::ofContent('a.php', '<?php $foo=1; echo $foo;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('ignores single-line comments', function () {
|
||||
$a = ContentHash::ofContent('a.php', "<?php\n// this is a comment\n\$foo = 1;");
|
||||
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('ignores hash-style comments', function () {
|
||||
$a = ContentHash::ofContent('a.php', "<?php\n# hash comment\n\$foo = 1;");
|
||||
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('ignores multi-line comments', function () {
|
||||
$a = ContentHash::ofContent('a.php', "<?php\n/* a multi\n line comment */\n\$foo = 1;");
|
||||
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('ignores doc comments', function () {
|
||||
$a = ContentHash::ofContent('a.php', "<?php\n/**\n * @return int\n */\nfunction foo() { return 1; }");
|
||||
$b = ContentHash::ofContent('a.php', "<?php\nfunction foo() { return 1; }");
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('detects code changes', function () {
|
||||
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
|
||||
$b = ContentHash::ofContent('a.php', '<?php $foo = 2;');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('preserves whitespace inside string literals', function () {
|
||||
$a = ContentHash::ofContent('a.php', "<?php \$foo = 'hello world';");
|
||||
$b = ContentHash::ofContent('a.php', "<?php \$foo = 'helloworld';");
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('treats variable renames as a change', function () {
|
||||
$a = ContentHash::ofContent('a.php', '<?php $foo = 1;');
|
||||
$b = ContentHash::ofContent('a.php', '<?php $bar = 1;');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('falls back to a raw hash for unparseable PHP', function () {
|
||||
$hash = ContentHash::ofContent('a.php', 'not valid php at all');
|
||||
|
||||
expect($hash)->toBeString()->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('is case-insensitive on the file extension', function () {
|
||||
$a = ContentHash::ofContent('a.PHP', "<?php\n// comment\n\$foo = 1;");
|
||||
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blade files', function () {
|
||||
it('strips blade comments', function () {
|
||||
$a = ContentHash::ofContent('a.blade.php', '<div>{{-- a comment --}}Hello</div>');
|
||||
$b = ContentHash::ofContent('a.blade.php', '<div>Hello</div>');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('strips multi-line blade comments', function () {
|
||||
$a = ContentHash::ofContent('a.blade.php', "<div>\n{{--\n multi\n line\n--}}\nHello\n</div>");
|
||||
$b = ContentHash::ofContent('a.blade.php', '<div> Hello </div>');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('collapses whitespace', function () {
|
||||
$a = ContentHash::ofContent('a.blade.php', "<div>\n Hello\n World\n</div>");
|
||||
$b = ContentHash::ofContent('a.blade.php', '<div> Hello World </div>');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('detects content changes', function () {
|
||||
$a = ContentHash::ofContent('a.blade.php', '<div>Hello</div>');
|
||||
$b = ContentHash::ofContent('a.blade.php', '<div>Goodbye</div>');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('keeps blade directives intact', function () {
|
||||
$a = ContentHash::ofContent('a.blade.php', '@if($user)Hi @endif');
|
||||
$b = ContentHash::ofContent('a.blade.php', '@if($user)Bye @endif');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('does not use the PHP tokenizer for blade files', function () {
|
||||
$a = ContentHash::ofContent('a.blade.php', '<?php // not stripped ?> hello');
|
||||
$b = ContentHash::ofContent('a.blade.php', '<?php ?> hello');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JavaScript-like files', function () {
|
||||
it('strips line comments', function () {
|
||||
$a = ContentHash::ofContent('a.js', "// a comment\nconst foo = 1;");
|
||||
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('strips block comments on their own lines', function () {
|
||||
$a = ContentHash::ofContent('a.js', "/* block */\nconst foo = 1;");
|
||||
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('collapses whitespace', function () {
|
||||
$a = ContentHash::ofContent('a.js', "const foo = 1;\n\nconst bar = 2;");
|
||||
$b = ContentHash::ofContent('a.js', 'const foo = 1; const bar = 2;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('detects code changes', function () {
|
||||
$a = ContentHash::ofContent('a.js', 'const foo = 1;');
|
||||
$b = ContentHash::ofContent('a.js', 'const foo = 2;');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('does not strip inline trailing comments', function () {
|
||||
$a = ContentHash::ofContent('a.js', 'const foo = 1; // inline');
|
||||
$b = ContentHash::ofContent('a.js', 'const foo = 1;');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('applies the same rules to .ts files', function () {
|
||||
$a = ContentHash::ofContent('a.ts', "// comment\nconst foo: number = 1;");
|
||||
$b = ContentHash::ofContent('a.ts', 'const foo: number = 1;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('applies the same rules to .tsx files', function () {
|
||||
$a = ContentHash::ofContent('a.tsx', "// comment\nconst Foo = () => <div/>;");
|
||||
$b = ContentHash::ofContent('a.tsx', 'const Foo = () => <div/>;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('applies the same rules to .jsx files', function () {
|
||||
$a = ContentHash::ofContent('a.jsx', "// comment\nconst Foo = () => <div/>;");
|
||||
$b = ContentHash::ofContent('a.jsx', 'const Foo = () => <div/>;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('applies the same rules to .vue files', function () {
|
||||
$a = ContentHash::ofContent('a.vue', "<script>\n// comment\nexport default {}\n</script>");
|
||||
$b = ContentHash::ofContent('a.vue', '<script> export default {} </script>');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('applies the same rules to .svelte files', function () {
|
||||
$a = ContentHash::ofContent('a.svelte', "<script>\n// comment\nlet foo = 1;\n</script>");
|
||||
$b = ContentHash::ofContent('a.svelte', '<script> let foo = 1; </script>');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('applies the same rules to .mjs, .cjs, and .mts files', function () {
|
||||
foreach (['mjs', 'cjs', 'mts'] as $ext) {
|
||||
$a = ContentHash::ofContent("a.$ext", "// comment\nexport const foo = 1;");
|
||||
$b = ContentHash::ofContent("a.$ext", 'export const foo = 1;');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown extensions', function () {
|
||||
it('hashes the raw content for unknown extensions', function () {
|
||||
$a = ContentHash::ofContent('a.txt', 'hello world');
|
||||
$b = ContentHash::ofContent('a.txt', 'hello world');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
|
||||
it('does not normalise whitespace for unknown extensions', function () {
|
||||
$a = ContentHash::ofContent('a.txt', 'hello world');
|
||||
$b = ContentHash::ofContent('a.txt', 'hello world');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('does not strip comments for unknown extensions', function () {
|
||||
$a = ContentHash::ofContent('a.txt', "// not a comment here\nhello");
|
||||
$b = ContentHash::ofContent('a.txt', 'hello');
|
||||
|
||||
expect($a)->not->toBe($b);
|
||||
});
|
||||
|
||||
it('hashes files with no extension as raw content', function () {
|
||||
$a = ContentHash::ofContent('Makefile', "all:\n\techo hi");
|
||||
$b = ContentHash::ofContent('Makefile', "all:\n\techo hi");
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('output format', function () {
|
||||
it('returns a 32-character hex xxh128 hash', function () {
|
||||
$hash = ContentHash::ofContent('a.php', '<?php $foo = 1;');
|
||||
|
||||
expect($hash)->toMatch('/^[a-f0-9]{32}$/');
|
||||
});
|
||||
|
||||
it('returns a stable hash for empty content', function () {
|
||||
$a = ContentHash::ofContent('a.php', '');
|
||||
$b = ContentHash::ofContent('a.php', '');
|
||||
|
||||
expect($a)->toBe($b);
|
||||
});
|
||||
});
|
||||
@ -16,6 +16,7 @@ $run = function () {
|
||||
|
||||
test('parallel', function () use ($run) {
|
||||
$output = $run('--exclude-group=integration');
|
||||
$output = implode("\n", array_slice(explode("\n", $output), -10));
|
||||
|
||||
if (getenv('REBUILD_SNAPSHOTS')) {
|
||||
preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches);
|
||||
@ -23,13 +24,13 @@ test('parallel', function () use ($run) {
|
||||
$file = file_get_contents(__FILE__);
|
||||
$file = preg_replace(
|
||||
'/\$expected = \'.*?\';/',
|
||||
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1278 passed (2920 assertions)';",
|
||||
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1312 passed (2957 assertions)';",
|
||||
$file,
|
||||
);
|
||||
file_put_contents(__FILE__, $file);
|
||||
}
|
||||
|
||||
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1278 passed (2920 assertions)';
|
||||
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1312 passed (2957 assertions)';
|
||||
|
||||
expect($output)
|
||||
->toContain("Tests: {$expected}")
|
||||
|
||||
Reference in New Issue
Block a user