mirror of
https://github.com/pestphp/pest.git
synced 2026-07-28 20:38:57 +02:00
Compare commits
228 Commits
f355b99bbf
...
v5.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 759ac63139 | |||
| 333f469efb | |||
| c4ec925aa5 | |||
| 5c8cfed483 | |||
| 962510b7e3 | |||
| e9c268a584 | |||
| fa91fcf744 | |||
| c6073511a2 | |||
| b5e1b83bb4 | |||
| 1c11595504 | |||
| cd0e921158 | |||
| f8c4c061f8 | |||
| 81eacd79fd | |||
| 1ef680c75d | |||
| 820fa08313 | |||
| 9e79e491e2 | |||
| 1b83b8fcda | |||
| ff4aba2ffa | |||
| feaac1ab7e | |||
| bbc6ada21e | |||
| 3927dbfcdf | |||
| b52bbb4cca | |||
| 52819501eb | |||
| 7b18287374 | |||
| 33e3bf2888 | |||
| 72ab506d7b | |||
| 5dc49a71d6 | |||
| 1adb484d3f | |||
| aadf7e92a6 | |||
| df42b9ed7b | |||
| 632ae79c8b | |||
| b5fed82883 | |||
| 145e20db75 | |||
| ee2e97e932 | |||
| b561236325 | |||
| e685179b7e | |||
| ac8feafdcc | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 87882a8561 | |||
| 77ef7e0df1 | |||
| 12100dd901 | |||
| 20c12d006e | |||
| 3aab02d2eb | |||
| 553aac65e6 | |||
| be49a3ce18 | |||
| 774a340400 | |||
| 04c9d41895 | |||
| dfb7b870af | |||
| 37821e1108 | |||
| cd711a25d5 | |||
| eee60c9e11 | |||
| c40c8dbc24 | |||
| 2d93c9c373 | |||
| 3d5bba93f8 | |||
| e5ab3af05b | |||
| 79bc7a8257 | |||
| 40b88b62ef | |||
| e3361bc321 | |||
| fc48c1bd1e | |||
| 92e76eb5ab | |||
| da726beffc | |||
| 4ef12b9aac | |||
| bd22f478b8 | |||
| eeaac34cf6 | |||
| b9b07d8983 | |||
| 6aa7d2f891 | |||
| 4d550cecfd | |||
| 1c21a7647a | |||
| d649de1988 | |||
| 783ca4bcd6 | |||
| ba07497219 | |||
| 34695843b3 | |||
| 1ca021dea6 | |||
| d17be9decd | |||
| b828ddcec7 | |||
| f859bb179d | |||
| 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 | |||
| c38d32ae86 | |||
| 6407c4f78f | |||
| 6e1bf63f6a | |||
| 1d3e8bb5dd | |||
| 3cc9b169e3 | |||
| c4911d046b | |||
| d0295f6168 | |||
| 21efbc3107 | |||
| e59b99cd73 | |||
| bf48e20880 | |||
| 53db68e005 | |||
| 34f1e9a7f2 | |||
| 57fd5ce042 | |||
| 3bcabfb63b | |||
| aa3a7c303a | |||
| 5c08a135f7 | |||
| 6e0e030d71 | |||
| b2c07561e7 | |||
| 97600b6f0b | |||
| 8a51f15d65 | |||
| a349f53964 | |||
| a725e774c0 | |||
| bed5e5b54a | |||
| 45b1d4ce20 | |||
| d106b70766 | |||
| 6ac6c1518e | |||
| fda515a17f | |||
| 0a97d3a288 | |||
| 3802fa80e6 | |||
| 5c3cbc14d2 | |||
| 6b9c768172 | |||
| 4a2fc179ae | |||
| b5bb2139dc | |||
| 07416a3c61 | |||
| 30b94e3034 | |||
| be34eecb2f | |||
| 5d9f95f8d4 | |||
| 48b70a03d5 | |||
| 4b8642b972 | |||
| 8711d51eac | |||
| 58dfb6da64 | |||
| d7735d1faa | |||
| 6b59166f3c | |||
| 3a26028d17 | |||
| 3c91bf4ad2 | |||
| 6a434be0f6 | |||
| fcf5c27914 | |||
| 18bbca748f | |||
| f142aad8ad | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| e63a886f98 | |||
| 8dd650fd05 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| 668ca9f5de | |||
| f659a45311 | |||
| 12c1da29ee | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 |
@@ -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.
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -2,7 +2,7 @@ name: Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -24,16 +24,16 @@ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
@@ -44,15 +44,17 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.3-composer-
|
||||
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.4-composer-
|
||||
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||
|
||||
- name: Profanity Check
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -24,21 +24,18 @@ jobs:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||
symfony: ['7.4', '8.0']
|
||||
php: ['8.3', '8.4', '8.5']
|
||||
symfony: ['8.0']
|
||||
php: ['8.4', '8.5']
|
||||
dependency_version: [prefer-stable]
|
||||
exclude:
|
||||
- php: '8.3'
|
||||
symfony: '8.0'
|
||||
|
||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
@@ -51,7 +48,7 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
@@ -66,6 +63,8 @@ jobs:
|
||||
|
||||
- name: Install PHP dependencies
|
||||
shell: bash
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||
|
||||
- name: Unit Tests
|
||||
@@ -76,21 +75,3 @@ jobs:
|
||||
|
||||
- name: Integration Tests
|
||||
run: composer test:integration
|
||||
|
||||
# tests-tia records coverage inside its sandbox, which requires
|
||||
# pcov (or xdebug) in the process PHP. The main setup-php step is
|
||||
# `coverage: none` for speed — re-enable pcov here just for the
|
||||
# TIA step. Cheap: pcov startup is near-zero.
|
||||
- name: Enable pcov for TIA
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
coverage: pcov
|
||||
extensions: sockets
|
||||
|
||||
- name: TIA End-to-End Tests
|
||||
# Black-box tests drive Pest `--tia` against a throw-away sandbox.
|
||||
# First scenario takes ~60s (composer-installs the host Pest into a
|
||||
# cached template); subsequent clones are cheap.
|
||||
run: composer test:tia
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
||||
<p align="center">
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
||||
<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>
|
||||
|
||||
------
|
||||
|
||||
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
||||
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v5-is-here)**.
|
||||
|
||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
||||
|
||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||
- Follow the creator Nuno Maduro:
|
||||
@@ -28,26 +29,12 @@
|
||||
|
||||
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
|
||||
|
||||
|
||||
### Platinum Sponsors
|
||||
|
||||
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
||||
|
||||
### Gold Sponsors
|
||||
|
||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||
|
||||
### Premium Sponsors
|
||||
|
||||
- [Zapiet](https://zapiet.com/?ref=pestphp)
|
||||
- [Load Forge](https://loadforge.com/?ref=pestphp)
|
||||
- [Route4Me](https://route4me.com/pt?ref=pestphp)
|
||||
- [Nerdify](https://getnerdify.com/?ref=pestphp)
|
||||
- [Akaunting](https://akaunting.com/?ref=pestphp)
|
||||
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
|
||||
- **[PhpStorm](https://jb.gg/nuno)**
|
||||
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/)**
|
||||
|
||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||
|
||||
+3
-3
@@ -2,10 +2,10 @@
|
||||
|
||||
When releasing a new version of Pest there are some checks and updates that need to be done:
|
||||
|
||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
||||
> **For Pest v4 you should use the `4.x` branch instead.**
|
||||
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
|
||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||
- Run the tests locally using: `composer test`
|
||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Contracts\Restarter;
|
||||
use Pest\Kernel;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||
@@ -143,14 +145,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
// Get $rootPath based on $autoloadPath
|
||||
$rootPath = dirname($autoloadPath, 2);
|
||||
|
||||
// Re-execs PHP without Xdebug on TIA replay runs so repeat `--tia`
|
||||
// invocations aren't slowed by a coverage driver they don't use. Plain
|
||||
// `pest` runs are left alone — users may rely on Xdebug for IDE
|
||||
// breakpoints, step-through debugging, or custom tooling. See
|
||||
// XdebugGuard for the full decision (coverage / tia-rebuild / Xdebug
|
||||
// mode gates).
|
||||
\Pest\Support\XdebugGuard::maybeDrop($rootPath);
|
||||
|
||||
$input = new ArgvInput;
|
||||
|
||||
$testSuite = TestSuite::getInstance(
|
||||
@@ -201,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
try {
|
||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||
|
||||
$container = Container::getInstance();
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
assert($restarter instanceof Restarter);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $originalArguments);
|
||||
}
|
||||
|
||||
$result = $kernel->handle($originalArguments, $arguments);
|
||||
|
||||
$kernel->terminate();
|
||||
|
||||
+305
-112
@@ -1,55 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* TIA Vite dependency resolver.
|
||||
*
|
||||
* Spins up a throwaway headless Vite dev server using the project's
|
||||
* `vite.config.*`, walks every `resources/js/Pages/**` entry to warm
|
||||
* up the module graph, then serializes the graph as a reverse map:
|
||||
*
|
||||
* { "<abs source path>": ["<page component name>", ...], ... }
|
||||
*
|
||||
* The resulting JSON is written to stdout. Stderr is silent on
|
||||
* success so Pest can parse stdout without stripping.
|
||||
*
|
||||
* Why this exists: at TIA record time we need to know which Inertia
|
||||
* page components depend on each shared source file (Button.vue,
|
||||
* Layouts/*.vue, etc.) so a later edit to one of those files can
|
||||
* invalidate only the tests that rendered an affected page. Vite
|
||||
* already knows this via its module graph — we borrow it.
|
||||
*
|
||||
* Called from `Pest\Plugins\Tia\JsModuleGraph::build()` as:
|
||||
*
|
||||
* node bin/pest-tia-vite-deps.mjs <absoluteProjectRoot>
|
||||
*
|
||||
* Environment:
|
||||
* TIA_VITE_PAGES_DIR override the `resources/js/Pages` default.
|
||||
* TIA_VITE_TIMEOUT_MS override the 20s internal watchdog.
|
||||
*/
|
||||
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, relative, extname, posix, sep, join } from 'node:path'
|
||||
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 TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10)
|
||||
const PAGE_DIR_CANDIDATES = [
|
||||
'resources/js/Pages',
|
||||
'resources/js/pages',
|
||||
'assets/js/Pages',
|
||||
'assets/js/pages',
|
||||
'assets/Pages',
|
||||
'assets/pages',
|
||||
]
|
||||
|
||||
// Resolve Vite from the project's own `node_modules`, not from this
|
||||
// helper's location (which lives under `vendor/pestphp/pest/bin/` and
|
||||
// has no `node_modules`). `createRequire` anchored at the project
|
||||
// root walks up from there, matching the resolution behaviour any
|
||||
// project-local script would see.
|
||||
async function loadVite() {
|
||||
async function loadRolldown() {
|
||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||
const vitePath = projectRequire.resolve('vite')
|
||||
return await import(pathToFileURL(vitePath).href)
|
||||
let path = null
|
||||
|
||||
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||
|
||||
if (path === null) {
|
||||
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||
// dependency of the vite package rather than a top-level install.
|
||||
try {
|
||||
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||
path = viteRequire.resolve('rolldown')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (path === null) return null
|
||||
|
||||
return await import(pathToFileURL(path).href)
|
||||
}
|
||||
|
||||
const { createServer } = await loadVite()
|
||||
export function stripJsonComments(raw) {
|
||||
let out = ''
|
||||
let inString = false
|
||||
let quote = ''
|
||||
let inLine = false
|
||||
let inBlock = false
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const c = raw[i]
|
||||
const n = raw[i + 1]
|
||||
|
||||
if (inLine) {
|
||||
if (c === '\n') { inLine = false; out += c }
|
||||
continue
|
||||
}
|
||||
if (inBlock) {
|
||||
if (c === '*' && n === '/') { inBlock = false; i++ }
|
||||
continue
|
||||
}
|
||||
if (inString) {
|
||||
out += c
|
||||
if (c === '\\') { out += n ?? ''; i++; continue }
|
||||
if (c === quote) inString = false
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
|
||||
if (c === '/' && n === '/') { inLine = true; i++; continue }
|
||||
if (c === '/' && n === '*') { inBlock = true; i++; continue }
|
||||
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
|
||||
out += c
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
async function readJsonWithComments(path) {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
return JSON.parse(stripJsonComments(raw))
|
||||
}
|
||||
|
||||
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
let cfg
|
||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||
for (const [key, targets] of Object.entries(paths)) {
|
||||
if (!key.endsWith('/*')) continue
|
||||
const t0 = Array.isArray(targets) ? targets[0] : null
|
||||
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
|
||||
const aliasKey = key.slice(0, -2)
|
||||
if (alias[aliasKey] !== undefined) continue
|
||||
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
|
||||
}
|
||||
}
|
||||
return alias
|
||||
}
|
||||
|
||||
const VITE_CONFIG_FILES = [
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
'vite.config.cts',
|
||||
]
|
||||
|
||||
function resolveAliasTarget(projectRoot, target) {
|
||||
if (target.startsWith('/')) {
|
||||
// Vite resolves a leading slash against the project root, unless the
|
||||
// config author used a genuinely absolute path.
|
||||
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
||||
}
|
||||
|
||||
return resolve(projectRoot, target)
|
||||
}
|
||||
|
||||
async function usesLaravelVitePlugin(projectRoot) {
|
||||
const p = join(projectRoot, 'package.json')
|
||||
if (!existsSync(p)) return false
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
||||
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
let source = null
|
||||
|
||||
for (const name of VITE_CONFIG_FILES) {
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
try { source = await readFile(p, 'utf8') } catch { continue }
|
||||
break
|
||||
}
|
||||
|
||||
if (source !== null) {
|
||||
// The config is executable code we cannot import safely, so extract alias
|
||||
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
||||
// the value expression — covers `'@': '/resources/js'` as well as
|
||||
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
||||
// top-level comma; call arguments are kept via the paren group.
|
||||
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
||||
const key = m[1]
|
||||
if (alias[key] !== undefined) continue
|
||||
|
||||
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
||||
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
||||
if (!target || target.includes('*')) continue
|
||||
|
||||
alias[key] = resolveAliasTarget(projectRoot, target)
|
||||
}
|
||||
}
|
||||
|
||||
// laravel-vite-plugin registers '@' → resources/js by default.
|
||||
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
||||
alias['@'] = resolve(projectRoot, 'resources/js')
|
||||
}
|
||||
|
||||
return alias
|
||||
}
|
||||
|
||||
async function listPageFiles(pagesDir) {
|
||||
if (!existsSync(pagesDir)) return []
|
||||
@@ -69,14 +190,44 @@ 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)
|
||||
return rel.slice(0, rel.length - ext.length)
|
||||
}
|
||||
|
||||
function isLocalSpecifier(source, aliasKeys) {
|
||||
if (source.startsWith('.') || source.startsWith('/')) return true
|
||||
for (const key of aliasKeys) {
|
||||
if (source === key || source.startsWith(key + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -84,82 +235,121 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Boot Vite in middleware mode (no port binding, no HMR server).
|
||||
// We only need the module graph; transformRequest per page warms
|
||||
// it without running a bundle.
|
||||
const server = await createServer({
|
||||
configFile: undefined, // auto-detect vite.config.*
|
||||
root: PROJECT_ROOT,
|
||||
logLevel: 'silent',
|
||||
clearScreen: false,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: false,
|
||||
watch: null,
|
||||
},
|
||||
appType: 'custom',
|
||||
optimizeDeps: { disabled: true },
|
||||
})
|
||||
const loaded = await loadRolldown()
|
||||
|
||||
// Watchdog — don't let a pathological config hang the record run.
|
||||
const killer = setTimeout(() => {
|
||||
server.close().catch(() => {}).finally(() => process.exit(2))
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
// Reverse map: depSourcePath → Set<component name>.
|
||||
const reverse = new Map()
|
||||
|
||||
const pageComponentCache = new Map()
|
||||
for (const page of pages) {
|
||||
pageComponentCache.set(page, componentNameFor(page, pagesDir))
|
||||
if (loaded === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
for (const pagePath of pages) {
|
||||
const pageComponent = pageComponentCache.get(pagePath)
|
||||
const pageUrl = '/' + posix.relative(
|
||||
PROJECT_ROOT.split(sep).join('/'),
|
||||
pagePath.split(sep).join('/'),
|
||||
)
|
||||
const { rolldown } = loaded
|
||||
// The vite config is what the dev server actually resolves with — let it win.
|
||||
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
||||
const aliasKeys = Object.keys(alias)
|
||||
|
||||
try {
|
||||
await server.transformRequest(pageUrl, { ssr: false })
|
||||
} catch {
|
||||
// Transform errors (missing deps, syntax issues) shouldn't
|
||||
// poison the whole graph — skip this page and continue.
|
||||
continue
|
||||
const graph = new Map()
|
||||
|
||||
const collector = {
|
||||
name: 'pest-tia-collector',
|
||||
moduleParsed(info) {
|
||||
const id = info.id
|
||||
if (!id || id.startsWith('\0')) return
|
||||
const deps = new Set()
|
||||
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
graph.set(id, deps)
|
||||
},
|
||||
}
|
||||
|
||||
const externalBare = {
|
||||
name: 'pest-tia-external-bare',
|
||||
resolveId(source) {
|
||||
if (!source) return null
|
||||
if (isLocalSpecifier(source, aliasKeys)) return null
|
||||
return { id: source, external: true }
|
||||
},
|
||||
}
|
||||
|
||||
const assetStub = {
|
||||
name: 'pest-tia-asset-stub',
|
||||
load(id) {
|
||||
if (!id) return null
|
||||
if (ASSET_EXT_RE.test(id)) {
|
||||
return { code: 'export default null', moduleSideEffects: false }
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
|
||||
if (!pageModule) continue
|
||||
const input = Object.create(null)
|
||||
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
||||
|
||||
// BFS over importedModules, scoped to files inside the project.
|
||||
const visited = new Set()
|
||||
const queue = [pageModule]
|
||||
while (queue.length) {
|
||||
const mod = queue.shift()
|
||||
for (const imported of mod.importedModules) {
|
||||
const id = imported.file ?? imported.id
|
||||
if (!id || visited.has(id)) continue
|
||||
visited.add(id)
|
||||
const bundle = await rolldown({
|
||||
input,
|
||||
cwd: PROJECT_ROOT,
|
||||
resolve: {
|
||||
alias,
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||
},
|
||||
transform: { jsx: 'preserve' },
|
||||
treeshake: false,
|
||||
plugins: [externalBare, assetStub, collector],
|
||||
logLevel: 'silent',
|
||||
onLog: () => {},
|
||||
})
|
||||
|
||||
// Skip files outside the project root (node_modules, etc.)
|
||||
// and virtual modules (`\0`-prefixed ids from plugins).
|
||||
if (id.startsWith('\0')) continue
|
||||
if (!id.startsWith(PROJECT_ROOT)) continue
|
||||
try {
|
||||
await bundle.generate({ format: 'esm' })
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
|
||||
const rel = relative(PROJECT_ROOT, id).split(sep).join('/')
|
||||
const bucket = reverse.get(rel) ?? new Set()
|
||||
bucket.add(pageComponent)
|
||||
reverse.set(rel, bucket)
|
||||
const reverse = new Map()
|
||||
const transitiveCache = new Map()
|
||||
|
||||
queue.push(imported)
|
||||
const computeTransitive = (id, stack) => {
|
||||
const cached = transitiveCache.get(id)
|
||||
if (cached) return cached
|
||||
if (stack.has(id)) return null
|
||||
|
||||
stack.add(id)
|
||||
const acc = new Set()
|
||||
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
||||
// that dependency's subtree and must not be memoized — the ancestor call
|
||||
// completes it for the current traversal, but a cached copy would leak the
|
||||
// incomplete set into other pages' traversals.
|
||||
let complete = true
|
||||
const deps = graph.get(id)
|
||||
if (deps) {
|
||||
for (const dep of deps) {
|
||||
if (!dep || dep.startsWith('\0')) continue
|
||||
if (dep.startsWith(PROJECT_ROOT)) {
|
||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||
acc.add(rel)
|
||||
}
|
||||
if (stack.has(dep)) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
const child = computeTransitive(dep, stack)
|
||||
if (child) for (const r of child) acc.add(r)
|
||||
if (!transitiveCache.has(dep)) complete = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(killer)
|
||||
await server.close()
|
||||
stack.delete(id)
|
||||
if (complete) transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const pageComponent = componentNameFor(page, pagesDir)
|
||||
const reachable = computeTransitive(page, new Set())
|
||||
if (!reachable) continue
|
||||
for (const rel of reachable) {
|
||||
const bucket = reverse.get(rel) ?? new Set()
|
||||
bucket.add(pageComponent)
|
||||
reverse.set(rel, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = Object.create(null)
|
||||
@@ -171,11 +361,14 @@ async function main() {
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
try {
|
||||
// Node 20 dynamic-import path — some environments are pickier than others.
|
||||
void pathToFileURL // retained to silence tree-shakers referencing the import
|
||||
await main()
|
||||
} catch (err) {
|
||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||
process.exit(1)
|
||||
const invokedDirectly = process.argv[1] !== undefined
|
||||
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
await main()
|
||||
} catch (err) {
|
||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
+18
-19
@@ -17,21 +17,20 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"composer/xdebug-handler": "^3.0.5",
|
||||
"nunomaduro/collision": "^8.9.4",
|
||||
"php": "^8.4",
|
||||
"brianium/paratest": "^7.23.0",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"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.23",
|
||||
"symfony/process": "^7.4.8|^8.0.8"
|
||||
"pestphp/pest-plugin": "^5.0.0",
|
||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.0",
|
||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||
"phpunit/phpunit": "^13.2.4",
|
||||
"symfony/process": "^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.23",
|
||||
"phpunit/phpunit": ">13.2.4",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@@ -59,11 +58,13 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"mrpunyapal/peststan": "^0.2.5",
|
||||
"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"
|
||||
"laravel/pao": "^1.1.2",
|
||||
"pestphp/pest-dev-tools": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||
"pestphp/pest-plugin-rector": "^5.0.0",
|
||||
"pestphp/pest-plugin-type-coverage": "^5.0.0",
|
||||
"psy/psysh": "^0.12.24"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
@@ -93,7 +94,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",
|
||||
@@ -101,8 +101,7 @@
|
||||
"@test:type:coverage",
|
||||
"@test:unit",
|
||||
"@test:parallel",
|
||||
"@test:integration",
|
||||
"@test:tia"
|
||||
"@test:integration"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2011 Brian Scaturro
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ParaTest\WrapperRunner;
|
||||
|
||||
use PHPUnit\TextUI\Output\Printer;
|
||||
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||
* instead of being treated as unexpected output.
|
||||
*/
|
||||
final readonly class ProgressPrinterOutput implements Printer
|
||||
{
|
||||
public function __construct(
|
||||
private Printer $progressPrinter,
|
||||
private Printer $outputPrinter,
|
||||
) {}
|
||||
|
||||
public function print(string $buffer): void
|
||||
{
|
||||
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||
if (
|
||||
$buffer === "\n"
|
||||
|| preg_match('/^ +$/', $buffer) === 1
|
||||
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($buffer) {
|
||||
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
||||
default => $this->outputPrinter->print($buffer),
|
||||
};
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->progressPrinter->flush();
|
||||
$this->outputPrinter->flush();
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||
* "T" and swallows the "S" that follows it.
|
||||
*/
|
||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if (str_contains($event->message(), '__TODO__')) {
|
||||
if ($event->message() === '__TODO__') {
|
||||
$this->printTodoItem();
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
$this->printer()->testSkipped();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
private function printTodoItem(): void
|
||||
{
|
||||
$mirror = new ReflectionClass($this->printer());
|
||||
$printerMirror = $mirror->getMethod('printProgress');
|
||||
$printerMirror->invoke($this->printer(), 'T');
|
||||
$printProgress = $mirror->getMethod('printProgress');
|
||||
$printProgress->invoke($this->printer(), 'T');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
- phpstan-pest-extension.neon
|
||||
- vendor/mrpunyapal/peststan/extension.neon
|
||||
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 7
|
||||
|
||||
+38
@@ -2,22 +2,60 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||
use Pest\Rector\Set\PestSetList;
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
|
||||
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
|
||||
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSets([
|
||||
PestSetList::CODING_STYLE,
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
__DIR__.'/tests/Fixtures/Arch',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
NarrowObjectReturnTypeRector::class,
|
||||
RemoveParentDelegatingConstructorRector::class,
|
||||
RemoveDuplicatedReturnSelfDocblockRector::class,
|
||||
RemoveUselessUnionReturnDocblockRector::class,
|
||||
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
||||
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
||||
],
|
||||
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
||||
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
||||
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
||||
UseToMatchArrayRector::class,
|
||||
// Test fixtures rely on "unused" constructors, params and properties
|
||||
// (resolved via the container or read through reflection), so the
|
||||
// dead-code and return-type rules below must not touch the test suite.
|
||||
RemoveEmptyClassMethodRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
RemoveUnusedConstructorParamRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
RemoveUnusedPrivatePropertyRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
AddArrowFunctionReturnTypeRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
||||
'INFO' => ['blue', 'INFO'],
|
||||
'ERROR' => ['red', 'ERROR'],
|
||||
'WARN' => ['yellow', 'WARN'],
|
||||
'SUCCESS' => ['green', 'SUCCESS'],
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const array FILES = [
|
||||
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||
'Runner/Filter/NameFilterIterator.php',
|
||||
'Runner/ResultCache/DefaultResultCache.php',
|
||||
'Runner/TestSuiteLoader.php',
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
||||
Subscribers\EnsureTiaCoverageIsFlushed::class,
|
||||
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
||||
Subscribers\EnsureTiaStarts::class,
|
||||
Subscribers\EnsureTiaEnds::class,
|
||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
||||
|
||||
+54
-88
@@ -8,11 +8,9 @@ use Closure;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\AutoloadEdges;
|
||||
use Pest\Plugins\Tia\BladeEdges;
|
||||
use Pest\Plugins\Tia\InertiaEdges;
|
||||
use Pest\Plugins\Tia\Collectors;
|
||||
use Pest\Plugins\Tia\Enums\ReplayType;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\TableTracker;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\Container;
|
||||
@@ -25,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
|
||||
use PHPUnit\Framework\IncompleteTest;
|
||||
use PHPUnit\Framework\SkippedTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||
use ReflectionException;
|
||||
use ReflectionFunction;
|
||||
use ReflectionParameter;
|
||||
@@ -84,10 +84,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.
|
||||
@@ -241,8 +246,6 @@ trait Testable
|
||||
{
|
||||
TestSuite::getInstance()->test = $this;
|
||||
|
||||
$this->__cachedPass = false;
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
$description = $method->description;
|
||||
@@ -275,72 +278,34 @@ trait Testable
|
||||
self::$__latestIssues = $method->issues;
|
||||
self::$__latestPrs = $method->prs;
|
||||
|
||||
// TIA replay short-circuit. Runs AFTER dataset/description/
|
||||
// assignee metadata is populated so output and filtering still
|
||||
// see the correct test name + tags on a cache hit, but BEFORE
|
||||
// `parent::setUp()` and `beforeEach` so we skip the user's
|
||||
// fixture setup (which is the whole point of replay — avoid
|
||||
// paying for work whose outcome we already know).
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
|
||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
||||
$replay = ReplayType::fromStatus($status);
|
||||
|
||||
if ($cached !== null) {
|
||||
if ($cached->isSuccess()) {
|
||||
$this->__cachedPass = true;
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
return;
|
||||
}
|
||||
match ($replay) {
|
||||
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'),
|
||||
};
|
||||
|
||||
// Risky tests have no public PHPUnit hook to replay as-risky.
|
||||
// Best available: short-circuit as a pass so the test doesn't
|
||||
// misreport as a failure. Aggregate risky totals won't
|
||||
// survive replay — accepted trade-off until PHPUnit grows a
|
||||
// programmatic risky-marker API.
|
||||
if ($cached->isRisky()) {
|
||||
$this->__cachedPass = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-success: throw the matching PHPUnit exception. Runner
|
||||
// catches it and marks the test with the correct status so
|
||||
// skips, failures, incompletes and todos appear in output
|
||||
// exactly as they did in the cached run.
|
||||
if ($cached->isSkipped()) {
|
||||
$this->markTestSkipped($cached->message());
|
||||
}
|
||||
|
||||
if ($cached->isIncomplete()) {
|
||||
$this->markTestIncomplete($cached->message());
|
||||
}
|
||||
|
||||
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = Container::getInstance()->get(Recorder::class);
|
||||
assert($recorder instanceof Recorder);
|
||||
|
||||
if ($recorder instanceof Recorder && $recorder->isActive()) {
|
||||
if ($recorder->isActive()) {
|
||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||
}
|
||||
|
||||
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
|
||||
? AutoloadEdges::snapshot()
|
||||
: [];
|
||||
|
||||
parent::setUp();
|
||||
|
||||
// TIA blade-edge + table-edge recording (Laravel-only). Runs
|
||||
// right after `parent::setUp()` so the Laravel app exists and
|
||||
// the View / DB facades are bound; each arm call is
|
||||
// idempotent against the current app instance so the 774-test
|
||||
// suite doesn't stack 774 composers / listeners when Laravel
|
||||
// keeps the same app across tests.
|
||||
if ($recorder instanceof Recorder) {
|
||||
BladeEdges::arm($recorder);
|
||||
TableTracker::arm($recorder);
|
||||
InertiaEdges::arm($recorder);
|
||||
}
|
||||
Collectors::armAll($recorder);
|
||||
|
||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||
|
||||
@@ -349,18 +314,13 @@ trait Testable
|
||||
}
|
||||
|
||||
$this->__callClosure($beforeEach, $arguments);
|
||||
}
|
||||
|
||||
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
||||
$recorder->linkSourcesForTest(
|
||||
self::$__filename,
|
||||
AutoloadEdges::newProjectFiles(
|
||||
$autoloadBeforeSetUp,
|
||||
AutoloadEdges::snapshot(),
|
||||
TestSuite::getInstance()->rootPath,
|
||||
self::$__filename,
|
||||
),
|
||||
);
|
||||
}
|
||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||
{
|
||||
$this->__replay = $replay;
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,7 +355,7 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
if ($this->__replay !== ReplayType::None) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
@@ -426,19 +386,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->getCachedAssertions($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;
|
||||
}
|
||||
@@ -480,10 +433,15 @@ trait Testable
|
||||
unset($this->{$property});
|
||||
}
|
||||
|
||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
||||
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||
|
||||
if ($hasOutputExpectation) {
|
||||
if ($outputBuffer->hasExpectation()) {
|
||||
ob_clean();
|
||||
|
||||
Closure::bind(function (): void {
|
||||
$this->expectedString = null;
|
||||
$this->expectedRegularExpression = null;
|
||||
}, $outputBuffer, OutputBuffer::class)();
|
||||
}
|
||||
|
||||
$this->setUp();
|
||||
@@ -499,7 +457,9 @@ trait Testable
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||
|
||||
$expectedClass = $read('expectedException');
|
||||
|
||||
@@ -507,13 +467,19 @@ trait Testable
|
||||
return $e instanceof $expectedClass;
|
||||
}
|
||||
|
||||
$expectedMessage = $read('expectedExceptionMessage');
|
||||
$expectedMessage = $read('expectedMessage');
|
||||
|
||||
if ($expectedMessage !== null) {
|
||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedExceptionCode');
|
||||
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||
|
||||
if ($expectedMessageRegex !== null) {
|
||||
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedCode');
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
return $e->getCode() === $expectedCode;
|
||||
|
||||
+11
-3
@@ -33,7 +33,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function in(string ...$targets): UsesCall
|
||||
{
|
||||
return (new UsesCall($this->filename, []))->in(...$targets);
|
||||
return new UsesCall($this->filename, [])->in(...$targets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +60,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function group(string ...$groups): UsesCall
|
||||
{
|
||||
return (new UsesCall($this->filename, []))->group(...$groups);
|
||||
return new UsesCall($this->filename, [])->group(...$groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function only(): void
|
||||
{
|
||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
||||
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,14 @@ final readonly class Configuration
|
||||
return new Browser\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the evals configuration.
|
||||
*/
|
||||
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
||||
{
|
||||
return new Evals\Configuration; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TIA (Test Impact Analysis) configuration.
|
||||
*/
|
||||
|
||||
@@ -59,12 +59,15 @@ final class Project
|
||||
/**
|
||||
* Sets the test project to GitLab.
|
||||
*/
|
||||
public function gitlab(string $project): self
|
||||
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
||||
{
|
||||
$this->issues = "https://gitlab.com/{$project}/issues/%s";
|
||||
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
|
||||
// Simple way to ensure only the host is used
|
||||
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
||||
|
||||
$this->assignees = 'https://gitlab.com/%s';
|
||||
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
||||
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
|
||||
|
||||
$this->assignees = "https://{$hostname}/%s";
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Restarter
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function maybeRestart(string $projectRoot, array $arguments): void;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Pest\Support\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $headline,
|
||||
private readonly string $hint,
|
||||
private readonly bool $hasAnchor = false,
|
||||
) {
|
||||
parent::__construct($headline);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
View::renderUsing($output);
|
||||
|
||||
if (! $this->hasAnchor) {
|
||||
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||
$output->writeln('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->renderChild($output, $this->headline);
|
||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||
$output->writeln('');
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function renderChild(OutputInterface $output, string $text): void
|
||||
{
|
||||
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class DatasetProviderError extends RuntimeException
|
||||
{
|
||||
public function __construct(Throwable $previous)
|
||||
{
|
||||
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $className, string $filename)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||
$className,
|
||||
$filename,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
|
||||
'',
|
||||
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
|
||||
sprintf(' in <fg=gray>%s</>.', $this->file),
|
||||
'',
|
||||
' Convert it to a Pest test, or run without Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $subdirectoryPrefix)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||
$this->subdirectoryPrefix,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
|
||||
'',
|
||||
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
|
||||
'',
|
||||
' Give the project its own git repository to use Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+15
-17
@@ -112,7 +112,7 @@ final class Expectation
|
||||
if (function_exists('dump')) {
|
||||
dump($this->value, ...$arguments);
|
||||
} else {
|
||||
var_dump($this->value);
|
||||
var_dump($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -120,16 +120,22 @@ final class Expectation
|
||||
|
||||
/**
|
||||
* Dump the expectation value and end the script.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function dd(mixed ...$arguments): void
|
||||
public function dd(mixed ...$arguments): never
|
||||
{
|
||||
if (function_exists('dd')) {
|
||||
dd($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
var_dump($this->value);
|
||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||
ob_start();
|
||||
var_dump($this->value, ...$arguments);
|
||||
$output = ob_get_clean();
|
||||
|
||||
throw new ExpectationFailedException($output);
|
||||
}
|
||||
|
||||
var_dump($this->value, ...$arguments);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
@@ -238,7 +244,7 @@ final class Expectation
|
||||
if ($callbacks[$index] instanceof Closure) {
|
||||
$callbacks[$index](new self($value), new self($key));
|
||||
} else {
|
||||
(new self($value))->toEqual($callbacks[$index]);
|
||||
new self($value)->toEqual($callbacks[$index]);
|
||||
}
|
||||
|
||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||
@@ -915,15 +921,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||
"to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -1138,8 +1136,8 @@ final class Expectation
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||
&& $object->reflectionClass->isEnum()
|
||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
'to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
|
||||
* Dynamically calls methods on the class with the given arguments.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
||||
/**
|
||||
* Accesses properties in the value or in the expectation.
|
||||
*
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
{
|
||||
|
||||
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($traits): bool {
|
||||
foreach ($traits as $trait) {
|
||||
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||
"not to use traits '".implode("', '", $traits)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||
"not to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$exporter = Exporter::default();
|
||||
|
||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
||||
|
||||
throw new ExpectationFailedException(sprintf(
|
||||
'Expecting %s not %s %s.',
|
||||
$toString($this->original->value),
|
||||
$exporter->shortenedExport($this->original->value),
|
||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| ! $object->reflectionClass->isEnum()
|
||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
'not to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -94,7 +94,8 @@ final class TestCaseFactory
|
||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||
}
|
||||
|
||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
||||
$realpath = (string) realpath($filename);
|
||||
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||
|
||||
@@ -149,6 +150,8 @@ final class TestCaseFactory
|
||||
|
||||
$attributesCode = Attributes::code($this->attributes);
|
||||
|
||||
$filenameLiteral = var_export($realpath, true);
|
||||
|
||||
$methodsCode = implode('', array_map(
|
||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||
$methods
|
||||
@@ -158,6 +161,7 @@ final class TestCaseFactory
|
||||
$classCode = <<<PHP
|
||||
namespace $namespace;
|
||||
|
||||
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||
use Pest\TestSuite as __PestTestSuite;
|
||||
|
||||
@@ -166,7 +170,7 @@ final class TestCaseFactory
|
||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||
$traitsCode
|
||||
|
||||
private static \$__filename = '$filename';
|
||||
public static \$__filename = $filenameLiteral;
|
||||
|
||||
$methodsCode
|
||||
}
|
||||
@@ -197,7 +201,7 @@ final class TestCaseFactory
|
||||
|
||||
if (
|
||||
$method->closure instanceof \Closure &&
|
||||
(new \ReflectionFunction($method->closure))->isStatic()
|
||||
new \ReflectionFunction($method->closure)->isStatic()
|
||||
) {
|
||||
|
||||
throw new TestClosureMustNotBeStatic($method);
|
||||
|
||||
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
|
||||
$attributesCode
|
||||
public function $methodName(...\$arguments)
|
||||
{
|
||||
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||
}
|
||||
|
||||
return \$this->__runTest(
|
||||
\$this->__test,
|
||||
...\$arguments,
|
||||
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
|
||||
|
||||
public static function $dataProviderName()
|
||||
{
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
try {
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
} catch (\Throwable \$throwable) {
|
||||
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||
}
|
||||
}
|
||||
|
||||
EOF;
|
||||
|
||||
+32
-9
@@ -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,
|
||||
@@ -44,15 +50,22 @@ final readonly class Kernel
|
||||
Bootstrappers\BootExcludeList::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The Kernel restarters — resolved and invoked from `bin/pest`
|
||||
* before any other Pest class is touched, so the list is exposed
|
||||
* on the Kernel rather than driven from `bin/pest` directly.
|
||||
*
|
||||
* @var array<int, class-string<Contracts\Restarter>>
|
||||
*/
|
||||
public const array RESTARTERS = [
|
||||
Restarters\XdebugRestarter::class,
|
||||
Restarters\PcovRestarter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -113,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,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);
|
||||
@@ -140,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;
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
+23
-20
@@ -12,12 +12,9 @@ use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||
use PHPUnit\Event\TestSuite\TestSuite;
|
||||
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
||||
use PHPUnit\Framework\Exception as FrameworkException;
|
||||
@@ -252,23 +249,29 @@ final readonly class Converter
|
||||
...$result->testMarkedIncompleteEvents(),
|
||||
];
|
||||
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored
|
||||
|| $event instanceof AfterLastTestMethodErrored) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
$notPassedTests = [];
|
||||
|
||||
return $this->getTestCaseLocation($event->test());
|
||||
},
|
||||
$events
|
||||
)
|
||||
)
|
||||
);
|
||||
foreach ($events as $event) {
|
||||
if ($event instanceof AfterLastTestMethodErrored) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
continue;
|
||||
}
|
||||
if ($event instanceof AfterLastTestMethodFailed) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
continue;
|
||||
}
|
||||
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
|
||||
$notPassedTests[] = $event->testClassName();
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||
}
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun()
|
||||
- count(array_unique($notPassedTests))
|
||||
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||
|
||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if ($event->message() === '__TODO__') {
|
||||
return; // "todo" tests are reported in the summary, not as ignored tests...
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,12 @@ final class TeamCityLogger
|
||||
$telemetry->memoryUsageSinceStart(),
|
||||
$telemetry->durationSincePrevious(),
|
||||
$telemetry->memoryUsageSincePrevious(),
|
||||
$telemetry->userCpuTimeSinceStart(),
|
||||
$telemetry->systemCpuTimeSinceStart(),
|
||||
$telemetry->totalCpuTimeSinceStart(),
|
||||
$telemetry->userCpuTimeSincePrevious(),
|
||||
$telemetry->systemCpuTimeSincePrevious(),
|
||||
$telemetry->totalCpuTimeSincePrevious(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+134
-1
@@ -936,7 +936,7 @@ final class Expectation
|
||||
|
||||
if ($exception instanceof Closure) {
|
||||
$callback = $exception;
|
||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
||||
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||
|
||||
if (count($parameters) !== 1) {
|
||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||
@@ -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);
|
||||
@@ -1141,6 +1142,22 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a ULID.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeUlid(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue(Str::isUlid($this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is between 2 specified values
|
||||
*
|
||||
@@ -1154,6 +1171,22 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is an email address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeEmail(string $message = ''): self
|
||||
{
|
||||
if ($message === '') {
|
||||
$message = "Failed asserting that {$this->value} is an email address.";
|
||||
}
|
||||
|
||||
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a url
|
||||
*
|
||||
@@ -1186,4 +1219,104 @@ final class Expectation
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is an IP address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeIpAddress(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a MAC address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeMacAddress(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a hostname.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeHostname(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a domain name.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeDomain(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
|
||||
&& str_contains($this->value, '.');
|
||||
|
||||
Assert::assertTrue($isValid, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a base64-encoded string.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeBase64(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
$decoded = base64_decode($this->value, true);
|
||||
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a hexadecimal string.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeHexadecimal(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue(ctype_xdigit($this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use PHPStan\Type\Type;
|
||||
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||
*
|
||||
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
|
||||
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
|
||||
|
||||
$varType = $scope->getType($expr->var);
|
||||
|
||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
||||
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ final class UsesCall
|
||||
$this->targets = [$filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
||||
*/
|
||||
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||
public function compact(): self
|
||||
{
|
||||
DefaultPrinter::compact(true);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '4.6.3';
|
||||
return '5.0.0-beta.1';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
||||
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||
return $this->pushArgument('--cache-result', $arguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ trait HandleArguments
|
||||
*/
|
||||
public function popArgument(string $argument, array $arguments): array
|
||||
{
|
||||
$arguments = array_flip($arguments);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
|
||||
unset($arguments[$argument]);
|
||||
while ($key !== false) {
|
||||
unset($arguments[$key]);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
}
|
||||
|
||||
return array_values(array_flip($arguments));
|
||||
return array_values($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class Coverage implements AddsOutput, HandlesArguments
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
private const string COVERAGE_OPTION = 'coverage';
|
||||
|
||||
private const string MIN_OPTION = 'min';
|
||||
@@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
return false;
|
||||
}))];
|
||||
|
||||
$originals = array_flip($originals);
|
||||
foreach ($arguments as $argument) {
|
||||
unset($originals[$argument]);
|
||||
$originals = $this->popArgument($argument, $originals);
|
||||
}
|
||||
$originals = array_flip($originals);
|
||||
|
||||
$inputs = [];
|
||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||
|
||||
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
|
||||
{
|
||||
$arguments = new ArgvInput;
|
||||
|
||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,10 +120,6 @@ final class ResultPrinter
|
||||
|
||||
$unexpectedOutput = $this->tail($outputFile);
|
||||
if ($unexpectedOutput !== '') {
|
||||
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->write($unexpectedOutput);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ namespace Pest\Plugins\Parallel\Paratest;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||
use ParaTest\Coverage\CoverageMerger;
|
||||
use ParaTest\JUnit\LogMerger;
|
||||
use ParaTest\JUnit\Writer;
|
||||
use ParaTest\Options;
|
||||
use ParaTest\RunnerInterface;
|
||||
use ParaTest\WrapperRunner\MissingResultsException;
|
||||
use ParaTest\WrapperRunner\SuiteLoader;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Result;
|
||||
@@ -25,11 +25,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||
use PHPUnit\Util\ExcludeList;
|
||||
use ReflectionProperty;
|
||||
use SebastianBergmann\CodeCoverage\Node\Builder;
|
||||
use SebastianBergmann\CodeCoverage\Serialization\Merger;
|
||||
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
|
||||
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
|
||||
use SebastianBergmann\Timer\Timer;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function array_filter;
|
||||
use function array_merge;
|
||||
use function array_merge_recursive;
|
||||
use function array_shift;
|
||||
@@ -37,6 +43,8 @@ use function assert;
|
||||
use function count;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function filesize;
|
||||
use function is_file;
|
||||
use function max;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
@@ -49,14 +57,14 @@ use function usleep;
|
||||
*/
|
||||
final class WrapperRunner implements RunnerInterface
|
||||
{
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
/**
|
||||
* The merged test result from the parallel run.
|
||||
*/
|
||||
public static ?TestResult $result = null;
|
||||
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
private const int CYCLE_SLEEP = 10000;
|
||||
|
||||
/**
|
||||
@@ -83,6 +91,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
/** @var array<int,int> */
|
||||
private array $batches = [];
|
||||
|
||||
/** @var array<non-empty-string,true> */
|
||||
private array $requiredTestResultFiles = [];
|
||||
|
||||
/** @var array<non-empty-string,true> */
|
||||
private array $requiredCoverageFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $statusFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $progressFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $unexpectedOutputFiles = [];
|
||||
|
||||
@@ -146,7 +166,6 @@ final class WrapperRunner implements RunnerInterface
|
||||
public function run(): int
|
||||
{
|
||||
$directory = dirname(__DIR__);
|
||||
assert($directory !== '');
|
||||
ExcludeList::addDirectory($directory);
|
||||
TestResultFacade::init();
|
||||
EventFacade::instance()->seal();
|
||||
@@ -213,7 +232,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
if (
|
||||
$this->exitcode > 0
|
||||
&& $this->options->configuration->stopOnFailure()
|
||||
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
||||
) {
|
||||
$this->pending = [];
|
||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||
@@ -228,6 +247,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
private function flushWorker(WrapperWorker $worker): void
|
||||
{
|
||||
if ($worker->hasExecutedTests()) {
|
||||
$testResultFile = $worker->testResultFile->getPathname();
|
||||
|
||||
if ($testResultFile !== '') {
|
||||
$this->requiredTestResultFiles[$testResultFile] = true;
|
||||
}
|
||||
|
||||
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
|
||||
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->exitcode = max($this->exitcode, $worker->getExitCode());
|
||||
$this->printer->printFeedback(
|
||||
$worker->progressFile,
|
||||
@@ -275,10 +306,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$worker->start();
|
||||
$this->batches[$token] = 0;
|
||||
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->statusFiles[] = $worker->statusFile;
|
||||
$this->progressFiles[] = $worker->progressFile;
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->testResultFiles[] = $worker->testResultFile;
|
||||
|
||||
if (isset($worker->resultCacheFile)) {
|
||||
$this->resultCacheFiles[] = $worker->resultCacheFile;
|
||||
}
|
||||
|
||||
if (isset($worker->junitFile)) {
|
||||
$this->junitFiles[] = $worker->junitFile;
|
||||
}
|
||||
@@ -311,6 +347,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
private function complete(TestResult $testResultSum): int
|
||||
{
|
||||
$missingTestResultFiles = [];
|
||||
|
||||
foreach ($this->requiredTestResultFiles as $filePath => $true) {
|
||||
if (is_file($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingTestResultFiles[] = $filePath;
|
||||
}
|
||||
|
||||
if ($missingTestResultFiles !== []) {
|
||||
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
|
||||
}
|
||||
|
||||
foreach ($this->testResultFiles as $testResultFile) {
|
||||
if (! $testResultFile->isFile()) {
|
||||
continue;
|
||||
@@ -345,6 +395,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueDeprecationEvents(), $testResult->testRunnerTriggeredIssueDeprecationEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueErrorEvents(), $testResult->testRunnerTriggeredIssueErrorEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueNoticeEvents(), $testResult->testRunnerTriggeredIssueNoticeEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(), $testResult->testRunnerTriggeredIssuePhpDeprecationEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(), $testResult->testRunnerTriggeredIssuePhpNoticeEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpWarningEvents(), $testResult->testRunnerTriggeredIssuePhpWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueWarningEvents(), $testResult->testRunnerTriggeredIssueWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||
@@ -380,8 +444,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||
array_values(array_filter(
|
||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
|
||||
)),
|
||||
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
|
||||
$testResultSum->errors(),
|
||||
$testResultSum->deprecations(),
|
||||
$testResultSum->notices(),
|
||||
@@ -417,8 +488,11 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||
|
||||
$this->clearFiles($this->statusFiles);
|
||||
$this->clearFiles($this->progressFiles);
|
||||
$this->clearFiles($this->unexpectedOutputFiles);
|
||||
$this->clearFiles($this->testResultFiles);
|
||||
$this->clearFiles($this->resultCacheFiles);
|
||||
$this->clearFiles($this->coverageFiles);
|
||||
$this->clearFiles($this->junitFiles);
|
||||
$this->clearFiles($this->teamcityFiles);
|
||||
@@ -433,6 +507,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$missingCoverageFiles = [];
|
||||
|
||||
foreach ($this->requiredCoverageFiles as $filePath => $true) {
|
||||
if (is_file($filePath) && filesize($filePath) !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingCoverageFiles[] = $filePath;
|
||||
}
|
||||
|
||||
if ($missingCoverageFiles !== []) {
|
||||
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
|
||||
}
|
||||
|
||||
$coverageManager = new CodeCoverage;
|
||||
$coverageManager->init(
|
||||
$this->options->configuration,
|
||||
@@ -448,10 +536,33 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
return;
|
||||
}
|
||||
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
||||
foreach ($this->coverageFiles as $coverageFile) {
|
||||
$coverageMerger->addCoverageFromFile($coverageFile);
|
||||
$coverageFiles = [];
|
||||
foreach ($this->coverageFiles as $fileInfo) {
|
||||
$realPath = $fileInfo->getRealPath();
|
||||
if ($realPath !== false && $realPath !== '') {
|
||||
$coverageFiles[] = $realPath;
|
||||
}
|
||||
}
|
||||
$serializedCoverage = (new Merger)->merge($coverageFiles);
|
||||
|
||||
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
|
||||
$serializedCoverage['codeCoverage'],
|
||||
$serializedCoverage['testResults'],
|
||||
$serializedCoverage['basePath'],
|
||||
);
|
||||
$codeCoverage = $coverageManager->codeCoverage();
|
||||
$codeCoverage->excludeUncoveredFiles();
|
||||
|
||||
$mergedData = $serializedCoverage['codeCoverage'];
|
||||
$basePath = $serializedCoverage['basePath'];
|
||||
if ($basePath !== '') {
|
||||
foreach ($mergedData->coveredFiles() as $relativePath) {
|
||||
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||
}
|
||||
}
|
||||
$codeCoverage->setData($mergedData);
|
||||
$codeCoverage->setTests($serializedCoverage['testResults']);
|
||||
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
|
||||
|
||||
$coverageManager->generateReports(
|
||||
$this->printer->printer,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||
use ParaTest\Options;
|
||||
use PHPUnit\Event\Telemetry\CpuTime;
|
||||
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\Telemetry\Info;
|
||||
@@ -147,11 +148,20 @@ final class CompactPrinter
|
||||
MemoryUsage::fromBytes(0),
|
||||
MemoryUsage::fromBytes(0),
|
||||
$garbageCollectorStatus,
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
),
|
||||
$telemetryDuration,
|
||||
MemoryUsage::fromBytes(0),
|
||||
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
|
||||
MemoryUsage::fromBytes(0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
);
|
||||
|
||||
$this->style->writeRecap($state, $telemetry, $testResult);
|
||||
|
||||
+105
-12
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string SHARD_OPTION = 'shard';
|
||||
|
||||
/**
|
||||
* The maximum length allowed for the filter argument.
|
||||
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
|
||||
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
|
||||
*/
|
||||
private const int MAX_FILTER_LENGTH = 32768;
|
||||
|
||||
/**
|
||||
* The shard index and total number of shards.
|
||||
*
|
||||
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
self::$timeBalanced = true;
|
||||
self::$shardsOutdated = $newTests !== [];
|
||||
} else {
|
||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||
}
|
||||
|
||||
self::$shard = [
|
||||
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||
$filter = $this->buildFilterArgument($testsToRun);
|
||||
|
||||
$this->ensureFilterLengthIsSafe($filter);
|
||||
|
||||
return [...$arguments, '--filter', $filter];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function allTests(array $arguments): array
|
||||
{
|
||||
$output = (new Process([
|
||||
'php',
|
||||
...$this->removeParallelArguments($arguments),
|
||||
'--list-tests',
|
||||
]))->setTimeout(120)->mustRun()->getOutput();
|
||||
$command = $this->buildListTestsCommand(
|
||||
$arguments,
|
||||
TestSuite::getInstance()->testPath,
|
||||
);
|
||||
|
||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
||||
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
return $this->parseListTestsOutput($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function removeParallelArguments(array $arguments): array
|
||||
{
|
||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
||||
return array_values(array_filter(
|
||||
$arguments,
|
||||
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
|
||||
&& ! str_starts_with($argument, '--processes'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the subprocess command used to enumerate tests via `--list-tests`.
|
||||
*
|
||||
* @param list<string> $arguments
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildListTestsCommand(array $arguments, string $testPath): array
|
||||
{
|
||||
$filtered = $this->removeParallelArguments($arguments);
|
||||
|
||||
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `--list-tests` output into a unique list of test class FQCNs.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseListTestsOutput(string $output): array
|
||||
{
|
||||
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the filter argument for the given tests to run.
|
||||
*
|
||||
* @param array<int, string> $testsToRun
|
||||
*/
|
||||
private function buildFilterArgument(mixed $testsToRun): string
|
||||
private function buildFilterArgument(array $testsToRun): string
|
||||
{
|
||||
return addslashes(implode('|', $testsToRun));
|
||||
if ($testsToRun === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $tree */
|
||||
$tree = [];
|
||||
foreach ($testsToRun as $class) {
|
||||
$parts = explode('\\', $class);
|
||||
$current = &$tree;
|
||||
foreach ($parts as $part) {
|
||||
if (! isset($current[$part])) {
|
||||
$current[$part] = [];
|
||||
}
|
||||
$current = &$current[$part];
|
||||
}
|
||||
}
|
||||
|
||||
$buildRegex = function (array $tree) use (&$buildRegex): string {
|
||||
$parts = [];
|
||||
foreach ($tree as $key => $sub) {
|
||||
$subRegex = $buildRegex($sub);
|
||||
if ($subRegex === '') {
|
||||
$parts[] = preg_quote($key, '/');
|
||||
} else {
|
||||
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
|
||||
}
|
||||
}
|
||||
|
||||
return implode('|', $parts);
|
||||
};
|
||||
|
||||
return $buildRegex($tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the filter length is safe for the current environment.
|
||||
*
|
||||
* @throws InvalidOption
|
||||
*/
|
||||
private function ensureFilterLengthIsSafe(string $filter): void
|
||||
{
|
||||
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
|
||||
|
||||
if (strlen($filter) > $maxLength) {
|
||||
throw new InvalidOption(sprintf(
|
||||
'The generated filter for this shard is too long (%d characters). '.
|
||||
'This can cause issues with some environments (limit is %d characters). '.
|
||||
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
|
||||
strlen($filter),
|
||||
$maxLength
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+751
-635
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Captures PHP files that were included while a test was running.
|
||||
*
|
||||
* Coverage drivers miss declaration-only files (classes, enums, interfaces,
|
||||
* traits) and files loaded before the coverage window opens. Diffing
|
||||
* `get_included_files()` gives TIA an explicit edge for those autoloaded files.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class AutoloadEdges
|
||||
{
|
||||
/**
|
||||
* @return array<string, true>
|
||||
*/
|
||||
public static function snapshot(): array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
foreach (get_included_files() as $file) {
|
||||
if (is_string($file) && $file !== '') {
|
||||
$files[$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $before
|
||||
* @param array<string, true> $after
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
|
||||
{
|
||||
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
|
||||
$out = [];
|
||||
|
||||
foreach (array_keys($after) as $file) {
|
||||
if (isset($before[$file])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$real = @realpath($file);
|
||||
if ($real === false) {
|
||||
$real = $file;
|
||||
}
|
||||
|
||||
if ($testReal !== false && $real === $testReal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($real, $root)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
|
||||
if (self::ignored($relative)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_ends_with($relative, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$real] = true;
|
||||
}
|
||||
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
private static function ignored(string $relative): bool
|
||||
{
|
||||
static $prefixes = [
|
||||
'vendor/',
|
||||
'node_modules/',
|
||||
'storage/framework/',
|
||||
'bootstrap/cache/',
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($relative, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+373
-271
@@ -4,89 +4,72 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Exceptions\BaselineFetchFailed;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Pulls a team-shared TIA baseline on the first `--tia` run so new
|
||||
* contributors and fresh CI workspaces start in replay mode instead of
|
||||
* paying the ~30s record cost.
|
||||
*
|
||||
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
|
||||
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
|
||||
* suite under `--tia` and uploads the `.pest/tia/` directory as a named
|
||||
* artifact (`pest-tia-baseline`) containing `graph.json` +
|
||||
* `coverage.bin`. On dev
|
||||
* machines, this class finds the latest successful run of that workflow
|
||||
* and downloads the artifact via `gh`.
|
||||
*
|
||||
* Why artifacts, not releases:
|
||||
* - No tag is created → no `push` event cascade into CI workflows.
|
||||
* - No release event → no deploy workflows tied to `release:published`.
|
||||
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
|
||||
* a single floating tag.
|
||||
* - Publishing is strictly CI-only: artifacts can't be produced from a
|
||||
* developer's laptop. This enforces the "CI is the authoritative
|
||||
* publisher" policy that local-publish paths would otherwise erode.
|
||||
*
|
||||
* Fingerprint validation happens back in `Tia::handleParent` after the
|
||||
* blobs are written: a mismatched environment (different PHP version,
|
||||
* composer.lock, etc.) discards the pulled baseline and falls through to
|
||||
* the regular record path.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class BaselineSync
|
||||
{
|
||||
/**
|
||||
* Conventional workflow filename teams publish from. Not configurable
|
||||
* for MVP — teams that outgrow the default can set
|
||||
* `PEST_TIA_BASELINE_WORKFLOW` later.
|
||||
*/
|
||||
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
||||
|
||||
/**
|
||||
* Artifact name the workflow uploads under. The artifact is a zip
|
||||
* containing `graph.json` (always) + `coverage.bin` (optional).
|
||||
*/
|
||||
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
||||
|
||||
/**
|
||||
* Asset filenames inside the artifact — mirror the state keys so the
|
||||
* CI publisher and the sync consumer stay in lock-step.
|
||||
*/
|
||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
/**
|
||||
* Cooldown (in seconds) applied after a failed baseline fetch.
|
||||
* Rationale: when the remote workflow hasn't published yet, every
|
||||
* `pest --tia` invocation would otherwise re-hit `gh run list` and
|
||||
* re-print the publish instructions — noisy + slow. Back off for a
|
||||
* day, let the user override with `--refetch`.
|
||||
*/
|
||||
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
|
||||
|
||||
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
|
||||
|
||||
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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Detects the repo, fetches the latest baseline artifact, writes its
|
||||
* contents into the TIA state store. Returns true when the graph blob
|
||||
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||
* never reads it.
|
||||
*
|
||||
* `$force = true` (driven by `--refetch`) ignores the post-failure
|
||||
* cooldown so the user can retry on demand without waiting out the
|
||||
* 24h window.
|
||||
*/
|
||||
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
|
||||
private function renderBadge(string $type, string $content): void
|
||||
{
|
||||
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
||||
}
|
||||
|
||||
private function renderChild(string $text): void
|
||||
{
|
||||
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||
}
|
||||
|
||||
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
|
||||
{
|
||||
$repo = $this->detectGitHubRepo($projectRoot);
|
||||
|
||||
@@ -95,25 +78,23 @@ final readonly class BaselineSync
|
||||
}
|
||||
|
||||
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
|
||||
.'Override with <fg=cyan>--refetch</>.',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
|
||||
$this->formatDuration($remaining),
|
||||
));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
$payload = $this->download($repo);
|
||||
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
||||
$payload = $result['payload'];
|
||||
$failureKind = $result['failureKind'];
|
||||
|
||||
if ($payload === null) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions($repo);
|
||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -126,23 +107,11 @@ final readonly class BaselineSync
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||
}
|
||||
|
||||
// Successful fetch wipes any stale cooldown so the next failure
|
||||
// (say, weeks later) starts a fresh 24h timer rather than inheriting
|
||||
// one from the deep past.
|
||||
$this->clearCooldown();
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds left on the cooldown, or `null` when the cooldown is cleared
|
||||
* / expired / unreadable.
|
||||
*/
|
||||
private function cooldownRemaining(): ?int
|
||||
{
|
||||
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
|
||||
@@ -187,62 +156,18 @@ final readonly class BaselineSync
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints actionable instructions for publishing a first baseline when
|
||||
* the consumer-side fetch finds nothing.
|
||||
*
|
||||
* Behaviour splits on environment:
|
||||
* - **CI:** a single line. The current run is almost certainly *the*
|
||||
* publisher (it's what this workflow does by definition), so
|
||||
* printing the whole recipe again is redundant and noisy.
|
||||
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
|
||||
* (`.env.example` copy + `artisan key:generate`) when the framework
|
||||
* is present. Generic PHP projects get a slimmer skeleton.
|
||||
*/
|
||||
private function emitPublishInstructions(string $repo): void
|
||||
private function emitPublishInstructions(): void
|
||||
{
|
||||
if ($this->isCi()) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
|
||||
);
|
||||
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$yaml = $this->isLaravel()
|
||||
? $this->laravelWorkflowYaml()
|
||||
: $this->genericWorkflowYaml();
|
||||
|
||||
$preamble = [
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
'',
|
||||
' To share the baseline with your team, add this workflow to the repo:',
|
||||
'',
|
||||
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
|
||||
'',
|
||||
];
|
||||
|
||||
$indentedYaml = array_map(
|
||||
static fn (string $line): string => ' '.$line,
|
||||
explode("\n", $yaml),
|
||||
);
|
||||
|
||||
$trailer = [
|
||||
'',
|
||||
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
|
||||
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
|
||||
'',
|
||||
];
|
||||
|
||||
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
|
||||
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
||||
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when running inside a CI provider. Conservative list — only the
|
||||
* three providers Pest formally supports / sees in the wild. `CI=true`
|
||||
* alone is ambiguous (users set it locally too) so we require a
|
||||
* provider-specific flag.
|
||||
*/
|
||||
private function isCi(): bool
|
||||
{
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
@@ -250,91 +175,6 @@ final readonly class BaselineSync
|
||||
|| getenv('CIRCLECI') === 'true';
|
||||
}
|
||||
|
||||
private function isLaravel(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel projects need a populated `.env` and a generated `APP_KEY`
|
||||
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
|
||||
* fires during `setUp`. Include the standard pre-test dance plus the
|
||||
* extension set typical Laravel apps rely on.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `.git/config` for the `origin` remote and extracts
|
||||
* `org/repo`. Supports the two URL flavours git emits out of the box.
|
||||
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
|
||||
* silently opts the repo out of auto-sync.
|
||||
*/
|
||||
private function detectGitHubRepo(string $projectRoot): ?string
|
||||
{
|
||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
@@ -349,29 +189,20 @@ YAML;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the `[remote "origin"]` section and the first `url` line
|
||||
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
|
||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $match[1];
|
||||
|
||||
// SSH: git@github.com:org/repo(.git)
|
||||
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
|
||||
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
|
||||
// Some teams configure this explicitly to pin the SSH port; the
|
||||
// colon-separated form above doesn't match. Mirrors the parser
|
||||
// in `Storage::originIdentity` so the same remote produces the
|
||||
// same project key for both storage and remote-fetch.
|
||||
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
@@ -380,74 +211,319 @@ YAML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-step fetch: find the latest successful run of the baseline
|
||||
* workflow, then download the named artifact from it. Returns
|
||||
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
|
||||
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
|
||||
* is missing, or any shell step fails.
|
||||
*
|
||||
* @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): ?array
|
||||
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
return null;
|
||||
}
|
||||
$this->validateGhDependencies($hasAnchor);
|
||||
|
||||
$runId = $this->latestSuccessfulRunId($repo);
|
||||
[$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 null;
|
||||
return ['payload' => null, 'failureKind' => 'no-runs'];
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
|
||||
return null;
|
||||
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.',
|
||||
'Install it from https://cli.github.com.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
if (! $this->ghAuthenticated()) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
||||
'Run `gh auth login` and retry.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, failureKind: ?string}
|
||||
*/
|
||||
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
|
||||
{
|
||||
$artifactSize = $this->artifactSize($repo, $runId);
|
||||
|
||||
$this->output->writeln('');
|
||||
$this->renderChild($artifactSize !== null
|
||||
? sprintf(
|
||||
'Downloading TIA baseline (%s) from %s…',
|
||||
$this->formatSize($artifactSize),
|
||||
$repo,
|
||||
)
|
||||
: sprintf(
|
||||
'Downloading TIA baseline from %s…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
$process = new Process([
|
||||
'gh', 'run', 'download', $runId,
|
||||
'-R', $repo,
|
||||
'-n', self::ARTIFACT_NAME,
|
||||
'-D', $tmpDir,
|
||||
'-D', $runCacheDir,
|
||||
]);
|
||||
$process->setTimeout(120.0);
|
||||
$process->setTimeout(900.0);
|
||||
$process->start();
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$tick = 0;
|
||||
|
||||
while ($process->isRunning()) {
|
||||
$this->renderDownloadProgress($startedAt, $tick++);
|
||||
usleep(120_000);
|
||||
}
|
||||
|
||||
$process->wait();
|
||||
$this->clearProgressLine();
|
||||
|
||||
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) {
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
||||
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function artifactSize(string $repo, string $runId): ?int
|
||||
{
|
||||
$process = new Process([
|
||||
'gh', 'api',
|
||||
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
||||
'--jq', sprintf(
|
||||
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
|
||||
self::ARTIFACT_NAME,
|
||||
),
|
||||
]);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
$size = trim($process->getOutput());
|
||||
|
||||
return is_numeric($size) ? (int) $size : null;
|
||||
}
|
||||
|
||||
private function renderDownloadProgress(float $startedAt, int $tick): void
|
||||
{
|
||||
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
$elapsed = max(0.0, microtime(true) - $startedAt);
|
||||
$frame = $frames[$tick % count($frames)];
|
||||
|
||||
$this->output->write(sprintf(
|
||||
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
|
||||
$frame,
|
||||
$elapsed,
|
||||
));
|
||||
}
|
||||
|
||||
private function clearProgressLine(): void
|
||||
{
|
||||
$this->output->write("\r\033[K");
|
||||
}
|
||||
|
||||
private function dirSize(string $dir): int
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
);
|
||||
|
||||
/** @var \SplFileInfo $entry */
|
||||
foreach ($iterator as $entry) {
|
||||
if ($entry->isFile()) {
|
||||
$total += $entry->getSize();
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
|
||||
*/
|
||||
private function readArtifact(string $dir): ?array
|
||||
{
|
||||
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
|
||||
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
|
||||
|
||||
if ($graph === false) {
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
|
||||
|
||||
$this->cleanup($tmpDir);
|
||||
|
||||
return [
|
||||
'graph' => $graph,
|
||||
'coverage' => $coverage === false ? null : $coverage,
|
||||
'sizeOnDisk' => $this->dirSize($dir),
|
||||
];
|
||||
}
|
||||
|
||||
private function downloadCacheDir(string $projectRoot): string
|
||||
{
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
|
||||
}
|
||||
|
||||
private function safeRunId(string $runId): string
|
||||
{
|
||||
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
||||
|
||||
return $sanitised === '' ? 'unknown' : $sanitised;
|
||||
}
|
||||
|
||||
private function trimDownloadCache(string $projectRoot): void
|
||||
{
|
||||
$root = $this->downloadCacheDir($projectRoot);
|
||||
|
||||
if (! is_dir($root)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = @scandir($root);
|
||||
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (in_array($entry, ['.', '..'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mtime = @filemtime($path);
|
||||
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
|
||||
}
|
||||
|
||||
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
usort(
|
||||
$candidates,
|
||||
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
|
||||
);
|
||||
|
||||
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
|
||||
$this->cleanup($stale['path']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries GitHub for the most recent successful run of the baseline
|
||||
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
|
||||
* into an empty string, which we map to null.
|
||||
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
||||
*/
|
||||
private function latestSuccessfulRunId(string $repo): ?string
|
||||
private function latestSuccessfulRunIdWithError(string $repo): array
|
||||
{
|
||||
$process = new Process([
|
||||
'gh', 'run', 'list',
|
||||
@@ -462,27 +538,49 @@ YAML;
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
|
||||
}
|
||||
|
||||
$runId = trim($process->getOutput());
|
||||
|
||||
return $runId === '' ? null : $runId;
|
||||
return [$runId === '' ? null : $runId, null];
|
||||
}
|
||||
|
||||
private function ghAuthenticated(): bool
|
||||
{
|
||||
$process = new Process(['gh', 'auth', 'status']);
|
||||
$process->setTimeout(10.0);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{kind: string, message: string}
|
||||
*/
|
||||
private function classifyGhError(string $output): array
|
||||
{
|
||||
$output = trim($output);
|
||||
|
||||
if ($output === '') {
|
||||
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
||||
}
|
||||
|
||||
foreach (self::DIAGNOSES as $kind => $diagnosis) {
|
||||
if (preg_match($diagnosis['pattern'], $output) === 1) {
|
||||
return ['kind' => $kind, 'message' => $diagnosis['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
|
||||
@@ -491,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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Laravel-only collaborator: during record mode, attributes every
|
||||
* rendered Blade view to the currently-running test.
|
||||
*
|
||||
* Why this exists: the coverage driver only sees compiled view files
|
||||
* under `storage/framework/views/<hash>.php`, not the `.blade.php`
|
||||
* source. Without a dedicated hook TIA has no edges for blade files,
|
||||
* so it leans on the Laravel WatchDefault's broad "any .blade.php
|
||||
* change → every feature test" fallback. Safe but noisy — editing a
|
||||
* single partial re-runs the whole suite.
|
||||
*
|
||||
* With this armed at record time, each test's edge set grows to
|
||||
* include the precise `.blade.php` files it rendered (directly or
|
||||
* through `@include`, layouts, components, Livewire, Inertia root
|
||||
* views — anything that goes through Laravel's view factory fires
|
||||
* `View::composer('*')`). Replay then invalidates exactly the tests
|
||||
* that rendered the changed template.
|
||||
*
|
||||
* Implementation note: everything Laravel-touching goes through
|
||||
* string class names, `class_exists`, and `method_exists` so Pest
|
||||
* core doesn't pull `illuminate/container` into its `require`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class BladeEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent. Tests call it
|
||||
* from every `setUp()`, and Laravel reuses the same app instance
|
||||
* across tests in most configurations — without this guard we'd
|
||||
* stack one composer per test and replay every one of them on
|
||||
* every view render.
|
||||
*/
|
||||
private const string MARKER = 'pest.tia.blade-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
{
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$containerClass = self::CONTAINER_CLASS;
|
||||
|
||||
if (! class_exists($containerClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var object $app */
|
||||
$app = $containerClass::getInstance();
|
||||
|
||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $app->bound('view')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$app->instance(self::MARKER, true);
|
||||
|
||||
$factory = $app->make('view');
|
||||
|
||||
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$factory->composer('*', static function (object $view) use ($recorder): void {
|
||||
if (! method_exists($view, 'getPath')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var mixed $path */
|
||||
$path = $view->getPath();
|
||||
|
||||
if (is_string($path) && $path !== '') {
|
||||
$recorder->linkSource($path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,17 +10,6 @@ use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* Plugin-level container registrations for TIA. Runs as part of Kernel's
|
||||
* bootstrapper chain so Tia's own service graph is set up without Kernel
|
||||
* having to know about any of its internals.
|
||||
*
|
||||
* Most Tia services (`Recorder`, `CoverageCollector`, `WatchPatterns`,
|
||||
* `ResultCollector`, `BaselineSync`) are auto-buildable — Pest's container
|
||||
* resolves them lazily via constructor reflection. The only service that
|
||||
* requires an explicit binding is the `State` contract, because the
|
||||
* filesystem implementation needs a root-directory string that reflection
|
||||
* can't infer.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Bootstrapper implements BootstrapperContract
|
||||
@@ -28,22 +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()));
|
||||
}
|
||||
|
||||
/**
|
||||
* TIA's per-project state directory. Default layout is
|
||||
* `~/.pest/tia/<project-key>/` so the graph survives `composer
|
||||
* install`, stays out of the project tree, and is naturally shared
|
||||
* across worktrees of the same repo. See {@see Storage} for the key
|
||||
* derivation and the home-dir-missing fallback.
|
||||
*/
|
||||
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,22 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Detects files that changed between the last recorded TIA run and the
|
||||
* current working tree.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. If we have a `recordedAtSha`, `git diff <sha>..HEAD` captures committed
|
||||
* changes on top of the recording point.
|
||||
* 2. `git status --short` captures unstaged + staged + untracked changes on
|
||||
* top of that.
|
||||
*
|
||||
* We return relative paths to the project root. Deletions are included so the
|
||||
* caller can decide whether to invalidate: a deleted source file may still
|
||||
* appear in the graph and should mark its dependents as affected.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class ChangedFiles
|
||||
@@ -27,33 +15,16 @@ final readonly class ChangedFiles
|
||||
public function __construct(private string $projectRoot) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push) — in
|
||||
* that case the graph should be rebuilt.
|
||||
*/
|
||||
/**
|
||||
* Removes files whose current content hash matches the snapshot from the
|
||||
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
|
||||
* that git still reports as modified but whose content is bit-identical
|
||||
* to the previous TIA invocation.
|
||||
*
|
||||
* @param array<int, string> $files project-relative paths.
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
||||
{
|
||||
if ($lastRunTree === []) {
|
||||
return $files;
|
||||
}
|
||||
|
||||
// Union: `$files` (what git currently reports) + every path that was
|
||||
// dirty last run. The second set matters for reverts — when a user
|
||||
// undoes a local edit, the file matches HEAD again and git reports
|
||||
// it clean, so it would never enter `$files`. But it has genuinely
|
||||
// changed vs the snapshot we captured during the bad run, so it
|
||||
// must be checked.
|
||||
$candidates = array_fill_keys($files, true);
|
||||
|
||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
||||
@@ -64,72 +35,30 @@ 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) {
|
||||
// File wasn't in last-run tree at all — trust git's signal.
|
||||
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $exists) {
|
||||
// Missing on disk. We always invalidate here, even when
|
||||
// the snapshot also recorded "deleted" (sentinel '').
|
||||
// The `snapshot=='' && !exists` shortcut would in
|
||||
// principle say "no change since last run, cached
|
||||
// result is still valid" — but it's only safe if the
|
||||
// cached result was recorded *during* a run that saw
|
||||
// the file as deleted. A previous run that captured
|
||||
// the deletion in `lastRunTree` but failed to refresh
|
||||
// the cached pass/fail (paratest worker race, an
|
||||
// earlier plugin bug, etc.) would leave the cache
|
||||
// stuck on a stale pass from before the deletion.
|
||||
// Skipping invalidation in that state perpetuates the
|
||||
// wrong result on every subsequent run. Treat any
|
||||
// missing file as a change; cost is one re-run per
|
||||
// `--tia` while the file stays deleted.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
if ($hash === false) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hash === $snapshot) {
|
||||
// Same state as the last TIA invocation — cached
|
||||
// result is still valid, no need to re-run.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Differs from the snapshot. This includes the
|
||||
// revert-back-to-baseline case (last run had a real edit
|
||||
// and was cached against that edit; this run reverted).
|
||||
// Even though the file now matches what's at the recorded
|
||||
// SHA, the cached test result reflects the *modified*
|
||||
// version, not the baseline version — so it's stale and
|
||||
// the test must re-run to refresh the cache. An earlier
|
||||
// version of this filter short-circuited on
|
||||
// matches-baseline, which served the stale failure
|
||||
// forever after the user reverted.
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes content hashes for the given project-relative files. Used to
|
||||
* snapshot the working tree after a successful run so the next run can
|
||||
* detect which files are actually different.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
*/
|
||||
@@ -141,9 +70,6 @@ final readonly class ChangedFiles
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Record the deletion with an empty-string sentinel so the
|
||||
// next run recognises "still deleted" as unchanged rather
|
||||
// than re-flagging the file as a fresh change.
|
||||
$out[$file] = '';
|
||||
|
||||
continue;
|
||||
@@ -161,15 +87,9 @@ final readonly class ChangedFiles
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push).
|
||||
*/
|
||||
public function since(?string $sha): ?array
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
if ($sha !== null && $sha !== '') {
|
||||
@@ -182,30 +102,17 @@ final readonly class ChangedFiles
|
||||
|
||||
$files = array_merge($files, $this->workingTreeChanges());
|
||||
|
||||
// Normalise + dedupe, filtering out paths that can never belong to the
|
||||
// graph: vendor (caught by the fingerprint instead), cache dirs, and
|
||||
// anything starting with a dot we don't care about.
|
||||
$unique = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->shouldIgnore($file)) {
|
||||
continue;
|
||||
}
|
||||
$unique[$file] = true;
|
||||
}
|
||||
|
||||
$candidates = array_keys($unique);
|
||||
$candidates = array_keys($this->filterIgnored($unique));
|
||||
|
||||
// Behavioural de-noising: for every file git calls "changed", hash
|
||||
// the current content and the content at `$sha` through
|
||||
// `ContentHash::of()`. A change that only touched comments /
|
||||
// whitespace / blade `{{-- --}}` blocks produces the same hash on
|
||||
// both sides and gets dropped before it can invalidate any test.
|
||||
// Without this, a single-comment edit on a migration re-runs the
|
||||
// entire DB-touching suite.
|
||||
if ($sha !== null && $sha !== '') {
|
||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||
}
|
||||
@@ -222,18 +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)) {
|
||||
// Deleted on disk — a genuine change, keep it.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentHash = ContentHash::of($absolute);
|
||||
|
||||
if ($currentHash === false) {
|
||||
if ($currentHash === null) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@@ -242,16 +140,12 @@ final readonly class ChangedFiles
|
||||
$baselineContent = $this->contentAtSha($sha, $file);
|
||||
|
||||
if ($baselineContent === null) {
|
||||
// Couldn't read the baseline (new file, binary, `git show`
|
||||
// failed). Err on the side of re-running.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineHash = ContentHash::ofContent($file, $baselineContent);
|
||||
|
||||
if ($currentHash !== $baselineHash) {
|
||||
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
}
|
||||
@@ -259,12 +153,6 @@ final readonly class ChangedFiles
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads `$path` at `$sha` via `git show`. Returns null when the file
|
||||
* didn't exist at that SHA, when git errors, or when the content
|
||||
* isn't valid UTF-8-safe bytes (rare — binary files that happen to
|
||||
* be tracked).
|
||||
*/
|
||||
private function contentAtSha(string $sha, string $path): ?string
|
||||
{
|
||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
||||
@@ -278,41 +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/',
|
||||
// Laravel regenerates these from manifest state
|
||||
// (package.json, service providers) at boot — they're
|
||||
// fully derived, not authored. Treating them as
|
||||
// "changes" just flaps the diff noisily.
|
||||
'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());
|
||||
@@ -320,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(
|
||||
@@ -336,9 +227,6 @@ final readonly class ChangedFiles
|
||||
);
|
||||
$process->run();
|
||||
|
||||
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
|
||||
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
|
||||
// "unreachable" and force a rebuild.
|
||||
return $process->getExitCode() === 0;
|
||||
}
|
||||
|
||||
@@ -354,7 +242,7 @@ final readonly class ChangedFiles
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
return $this->splitLines($process->getOutput());
|
||||
@@ -365,14 +253,6 @@ final readonly class ChangedFiles
|
||||
*/
|
||||
private function workingTreeChanges(): array
|
||||
{
|
||||
// `-z` produces NUL-terminated records with no path quoting, so paths
|
||||
// that contain spaces, tabs, unicode or other special characters
|
||||
// are passed through verbatim. Without `-z`, git wraps such paths in
|
||||
// quotes with backslash escapes, which would corrupt our lookup keys.
|
||||
//
|
||||
// Record format: `XY <SP> <path> <NUL>` for most entries, and
|
||||
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
|
||||
// fields).
|
||||
$process = new Process(
|
||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||
$this->projectRoot,
|
||||
@@ -380,7 +260,7 @@ final readonly class ChangedFiles
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
@@ -403,8 +283,6 @@ final readonly class ChangedFiles
|
||||
$status = substr($record, 0, 2);
|
||||
$path = substr($record, 3);
|
||||
|
||||
// Renames/copies emit two records: the new path first, then the
|
||||
// original. Consume both.
|
||||
if ($status[0] === 'R' || $status[0] === 'C') {
|
||||
$files[] = $path;
|
||||
|
||||
@@ -424,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());
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Edges\BladeEdges;
|
||||
use Pest\Plugins\Tia\Edges\InertiaEdges;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Collectors
|
||||
{
|
||||
/** @var list<class-string> */
|
||||
private const array COLLECTORS = [
|
||||
BladeEdges::class,
|
||||
TableTracker::class,
|
||||
InertiaEdges::class,
|
||||
];
|
||||
|
||||
public static function armAll(Recorder $recorder): void
|
||||
{
|
||||
foreach (self::COLLECTORS as $collector) {
|
||||
$collector::arm($recorder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,60 @@ namespace Pest\Plugins\Tia;
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* User-facing TIA configuration, returned by `pest()->tia()`.
|
||||
*
|
||||
* Usage in `tests/Pest.php`:
|
||||
*
|
||||
* pest()->tia()->watch([
|
||||
* 'resources/js/**\/*.tsx' => 'tests/Browser',
|
||||
* 'public/build/**\/*' => 'tests/Browser',
|
||||
* ]);
|
||||
*
|
||||
* Patterns are merged with the built-in defaults (config, routes, views,
|
||||
* frontend assets, migrations). Duplicate glob keys overwrite the default
|
||||
* mapping so users can redirect a pattern to a narrower directory.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* Adds watch-pattern → test-directory mappings that supplement (or
|
||||
* override) the built-in defaults.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function always(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markEnabled();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function locally(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markEnabled();
|
||||
$watchPatterns->markLocally();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function filtered(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markFiltered();
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -5,33 +5,10 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Per-file hashing that ignores changes which can't alter behaviour —
|
||||
* comments and whitespace for PHP, `{{-- … --}}` comments and whitespace
|
||||
* runs for Blade templates. Every other file type falls back to a plain
|
||||
* xxh128 of the raw bytes.
|
||||
*
|
||||
* Why it matters: TIA's file diff signals drive which tests re-run. A
|
||||
* one-line comment tweak on a migration is a behavioural no-op, but the
|
||||
* raw-bytes hash still differs, so every test that talks to the DB would
|
||||
* currently re-execute. Normalising to the parsed-token / compiled-shape
|
||||
* keeps the drift signal honest: edits that can't change runtime
|
||||
* behaviour don't invalidate the replay cache.
|
||||
*
|
||||
* Important: this hash is stored in the graph's last-run tree, so any
|
||||
* format change here must be paired with a `Fingerprint::SCHEMA_VERSION`
|
||||
* bump — otherwise stale hashes from older graphs would be compared
|
||||
* against normalised hashes from the new code and everything would
|
||||
* appear changed.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ContentHash
|
||||
{
|
||||
/**
|
||||
* xxh128 hex of the file's "behavioural" shape, or `false` when the
|
||||
* file can't be read. Callers should treat `false` the same way they
|
||||
* treated a failed `hash_file()` previously.
|
||||
*/
|
||||
public static function of(string $absolute): string|false
|
||||
{
|
||||
$raw = @file_get_contents($absolute);
|
||||
@@ -43,11 +20,6 @@ final class ContentHash
|
||||
return self::ofContent($absolute, $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `of()` but accepts the file contents in memory. Used when
|
||||
* we already have the bytes (e.g. from `git show <sha>:<path>`) and
|
||||
* want to avoid a disk round-trip.
|
||||
*/
|
||||
public static function ofContent(string $path, string $raw): string
|
||||
{
|
||||
$lower = strtolower($path);
|
||||
@@ -69,13 +41,6 @@ final class ContentHash
|
||||
return hash('xxh128', $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenise the content and hash the concatenated values of every
|
||||
* token except whitespace / comment / docblock. `token_get_all()`
|
||||
* is built-in, fast, and enough to collapse any formatting-only
|
||||
* edit. If tokenisation fails (rare syntax error), fall back to
|
||||
* the raw hash so the caller still gets a deterministic signal.
|
||||
*/
|
||||
private static function hashPhpContent(string $raw): string
|
||||
{
|
||||
$tokens = @token_get_all($raw);
|
||||
@@ -106,14 +71,6 @@ final class ContentHash
|
||||
return hash('xxh128', $normalised);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blade templates aren't PHP syntactically, so `token_get_all()`
|
||||
* doesn't help. Strip `{{-- … --}}` comments (the only Blade-native
|
||||
* comment form) and collapse whitespace runs. Output differences
|
||||
* that would survive the Blade compiler (markup reordering, new
|
||||
* directives, changed interpolation) still flip the hash; pure
|
||||
* reformatting does not.
|
||||
*/
|
||||
private static function hashBladeContent(string $raw): string
|
||||
{
|
||||
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
||||
@@ -122,17 +79,6 @@ final class ContentHash
|
||||
return hash('xxh128', trim($stripped));
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative JS/TS/Vue/Svelte normaliser. Strips `//` line
|
||||
* comments and `/* … *\/` block comments that appear on their own
|
||||
* lines (including leading indentation), then collapses
|
||||
* whitespace. Deliberately leaves trailing comments after code
|
||||
* alone — a string literal like `'http://foo'` would be unsafe to
|
||||
* split on `//` without a full lexer. The direction of error is
|
||||
* over-detection (we may not strip a trailing comment that's
|
||||
* purely cosmetic), never under-detection. Blank lines and
|
||||
* indentation changes are erased regardless.
|
||||
*/
|
||||
private static function hashJsContent(string $raw): string
|
||||
{
|
||||
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Lockfile
|
||||
{
|
||||
public function applies(string $filename): bool;
|
||||
|
||||
public function fingerprint(string $contents): ?string;
|
||||
}
|
||||
@@ -5,43 +5,19 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* Storage contract for TIA's persistent state (graph, baselines, affected
|
||||
* set, worker partials, coverage snapshots). Modelled as a flat key/value
|
||||
* store of raw byte blobs so implementations can sit on top of whatever
|
||||
* backend fits — a directory, a shared cache, a remote object store — and
|
||||
* TIA's logic stays identical.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface State
|
||||
{
|
||||
/**
|
||||
* Returns the stored blob for `$key`, or `null` when the key is unset
|
||||
* or cannot be read.
|
||||
*/
|
||||
public function read(string $key): ?string;
|
||||
|
||||
/**
|
||||
* Atomically stores `$content` under `$key`. Existing value (if any) is
|
||||
* replaced. Implementations SHOULD guarantee that concurrent readers
|
||||
* never observe partial writes.
|
||||
*/
|
||||
public function write(string $key, string $content): bool;
|
||||
|
||||
/**
|
||||
* Removes `$key`. Returns true whether or not the key existed beforehand
|
||||
* — callers should treat a `true` result as "the key is now absent",
|
||||
* not "the key was present and has been removed."
|
||||
*/
|
||||
public function delete(string $key): bool;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
/**
|
||||
* Returns every key whose name starts with `$prefix`. Used to collect
|
||||
* paratest worker partials (`worker-edges-<token>.json`, etc.) without
|
||||
* exposing backend-specific glob semantics.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keysWithPrefix(string $prefix): array;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface WatchDefault
|
||||
{
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
|
||||
*/
|
||||
public function defaults(string $projectRoot, string $testPath): array;
|
||||
}
|
||||
@@ -5,38 +5,19 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
|
||||
use ReflectionClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Extracts per-test file coverage from PHPUnit's shared `CodeCoverage`
|
||||
* instance. Used when TIA piggybacks on `--coverage` instead of starting
|
||||
* its own driver session — both share the same PCOV / Xdebug state, so
|
||||
* running two recorders in parallel would corrupt each other's data.
|
||||
*
|
||||
* PHPUnit tags every coverage sample with the current test's id
|
||||
* (`$test->valueObjectForEvents()->id()`, e.g. `Foo\BarTest::baz`). The
|
||||
* per-file / per-line coverage map therefore already carries everything
|
||||
* we need to rebuild TIA edges at the end of the run.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CoverageCollector
|
||||
{
|
||||
/**
|
||||
* Cached `className → test file` lookups. Class reflection is cheap
|
||||
* individually but the record run can visit tens of thousands of
|
||||
* samples, so the cache matters.
|
||||
*
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
private array $classFileCache = [];
|
||||
|
||||
/**
|
||||
* Rebuilds the same `absolute test file → list<absolute source file>`
|
||||
* shape that `Recorder::perTestFiles()` exposes, so callers can treat
|
||||
* the two collectors interchangeably when feeding the graph.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function perTestFiles(): array
|
||||
@@ -58,9 +39,6 @@ final class CoverageCollector
|
||||
$edges = [];
|
||||
|
||||
foreach ($lineCoverage as $sourceFile => $lines) {
|
||||
// Collect the set of tests that hit any line in this file once,
|
||||
// then emit one edge per (testFile, sourceFile) pair. Walking
|
||||
// the lines per test would re-resolve the test file repeatedly.
|
||||
$testIds = [];
|
||||
|
||||
foreach ($lines as $hits) {
|
||||
@@ -100,9 +78,6 @@ final class CoverageCollector
|
||||
|
||||
private function testIdToFile(string $testId): ?string
|
||||
{
|
||||
// PHPUnit's test id is `ClassName::methodName` with an optional
|
||||
// `#dataSetName` suffix for data-provider runs. Strip the dataset
|
||||
// part — we only need the class.
|
||||
$hash = strpos($testId, '#');
|
||||
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
|
||||
|
||||
@@ -128,25 +103,8 @@ final class CoverageCollector
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||
|
||||
// Pest's eval'd test classes expose the original `.php` path on a
|
||||
// static `$__filename`. The eval'd class itself has no file of its
|
||||
// own, so prefer this property when present.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,36 +8,12 @@ use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||
use SebastianBergmann\CodeCoverage\Filter;
|
||||
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Merges the current run's PHPUnit coverage into a cached full-suite
|
||||
* snapshot so `--tia --coverage` can produce a complete report after
|
||||
* executing only the affected tests.
|
||||
*
|
||||
* Invoked from `Pest\Support\Coverage::report()` right before the coverage
|
||||
* file is consumed. A marker dropped by the `Tia` plugin gates the
|
||||
* behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
|
||||
* and therefore keep their existing semantics.
|
||||
*
|
||||
* Algorithm
|
||||
* ---------
|
||||
* The PHPUnit coverage PHP file unserialises to a `CodeCoverage` object.
|
||||
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the
|
||||
* list of test IDs that covered that line. We:
|
||||
*
|
||||
* 1. Load the cached snapshot from `State` (serialised bytes).
|
||||
* 2. Strip every test id that re-ran this time from the cached map —
|
||||
* the tests that ran now are the ones whose attribution is fresh.
|
||||
* 3. Merge the current run into the stripped cached snapshot via
|
||||
* `CodeCoverage::merge()`.
|
||||
* 4. Write the merged result back to the report path (so Pest's report
|
||||
* generator sees the full suite) and back into `State` (for the
|
||||
* next invocation).
|
||||
*
|
||||
* If no cache exists yet (first `--tia --coverage` run on this machine)
|
||||
* we serialise the current object and save it — nothing to merge yet.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CoverageMerger
|
||||
@@ -46,7 +22,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;
|
||||
}
|
||||
|
||||
@@ -55,46 +31,66 @@ final class CoverageMerger
|
||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if ($cachedBytes === null) {
|
||||
// First `--tia --coverage` run: nothing cached yet, so the
|
||||
// current file already represents the full suite. Capture it
|
||||
// verbatim (as serialised bytes) for next time.
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current instanceof CodeCoverage) {
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
||||
self::primeUncoveredFiles($current);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cached = self::unserializeCoverage($cachedBytes);
|
||||
$decoded = self::decompress($cachedBytes);
|
||||
|
||||
if ($decoded === null) {
|
||||
$state->delete(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cached = self::unserializeCoverage($decoded);
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::primeUncoveredFiles($cached);
|
||||
self::primeUncoveredFiles($current);
|
||||
|
||||
self::stripCurrentTestsFromCached($cached, $current);
|
||||
|
||||
$cached->merge($current);
|
||||
|
||||
$serialised = serialize($cached);
|
||||
|
||||
// Write back to the PHPUnit-style `.cov` path so the report reader
|
||||
// can `require` it, and to the state cache for the next run.
|
||||
@file_put_contents(
|
||||
$reportPath,
|
||||
'<?php return unserialize('.var_export($serialised, true).");\n",
|
||||
);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
|
||||
$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);
|
||||
|
||||
return $compressed === false ? $bytes : $compressed;
|
||||
}
|
||||
|
||||
private static function decompress(string $bytes): ?string
|
||||
{
|
||||
$decoded = @gzdecode($bytes);
|
||||
|
||||
return $decoded === false ? null : $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes from `$cached`'s per-line test attribution any test id that
|
||||
* appears in `$current`. Those tests just ran, so the fresh slice is
|
||||
* authoritative — keeping stale attribution in the cache would claim
|
||||
* a test still covers a line it no longer touches.
|
||||
*/
|
||||
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
|
||||
{
|
||||
$currentIds = self::collectTestIds($current);
|
||||
@@ -147,15 +143,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
|
||||
@@ -171,7 +164,60 @@ final class CoverageMerger
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
|
||||
if ($value instanceof CodeCoverage) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
|
||||
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
|
||||
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
|
||||
// be rebuilt into one before it can be merged.
|
||||
return self::coverageFromSerializedData($reportPath);
|
||||
}
|
||||
|
||||
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
|
||||
{
|
||||
if ($reportPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$serialized = new Unserializer()->unserialize($reportPath);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $serialized['codeCoverage'];
|
||||
$basePath = $serialized['basePath'];
|
||||
|
||||
if ($basePath !== '') {
|
||||
foreach ($data->coveredFiles() as $relativePath) {
|
||||
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
$coverage = self::emptyCoverage();
|
||||
|
||||
if (! $coverage instanceof CodeCoverage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage->setData($data);
|
||||
$coverage->setTests($serialized['testResults']);
|
||||
|
||||
return $coverage;
|
||||
}
|
||||
|
||||
private static function emptyCoverage(): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
$filter = new Filter;
|
||||
|
||||
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BladeEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
private const string MARKER = 'pest.tia.blade-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
{
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$containerClass = self::CONTAINER_CLASS;
|
||||
|
||||
if (! class_exists($containerClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var object $app */
|
||||
$app = $containerClass::getInstance();
|
||||
|
||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$app->instance(self::MARKER, true);
|
||||
|
||||
$factory = $app->make('view');
|
||||
|
||||
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$factory->composer('*', static function (object $view) use ($recorder): void {
|
||||
if (! method_exists($view, 'getPath')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var mixed $path */
|
||||
$path = $view->getPath();
|
||||
|
||||
if (is_string($path) && $path !== '') {
|
||||
$recorder->linkSource($path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InertiaEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||
|
||||
private const string MARKER = 'pest.tia.inertia-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
{
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$containerClass = self::CONTAINER_CLASS;
|
||||
|
||||
if (! class_exists($containerClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var object $app */
|
||||
$app = $containerClass::getInstance();
|
||||
|
||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$app->instance(self::MARKER, true);
|
||||
|
||||
/** @var object $events */
|
||||
$events = $app->make('events');
|
||||
|
||||
if (! method_exists($events, 'listen')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
||||
if (! property_exists($event, 'response') || ! is_object($event->response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component = self::extractComponent($event->response);
|
||||
|
||||
if ($component !== null) {
|
||||
$recorder->linkInertiaComponent($component);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static function extractComponent(object $response): ?string
|
||||
{
|
||||
$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]));
|
||||
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_contains($content, 'data-page=')
|
||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||
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 */
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (is_array($decoded)
|
||||
&& isset($decoded['component'])
|
||||
&& is_string($decoded['component'])
|
||||
&& $decoded['component'] !== '') {
|
||||
return $decoded['component'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function readContent(object $response): ?string
|
||||
{
|
||||
if (! method_exists($response, 'getContent')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var mixed $content */
|
||||
$content = $response->getContent();
|
||||
|
||||
return is_string($content) ? $content : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Enums;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
enum ReplayType
|
||||
{
|
||||
case None;
|
||||
case Pass;
|
||||
case Risky;
|
||||
case Skipped;
|
||||
case Incomplete;
|
||||
case Failure;
|
||||
|
||||
public static function fromStatus(?TestStatus $status): self
|
||||
{
|
||||
if (! $status instanceof TestStatus) {
|
||||
return self::None;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$status->isSuccess() => self::Pass,
|
||||
$status->isRisky() => self::Risky,
|
||||
$status->isSkipped() => self::Skipped,
|
||||
$status->isIncomplete() => self::Incomplete,
|
||||
default => self::Failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,24 +7,13 @@ namespace Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
|
||||
/**
|
||||
* Filesystem-backed implementation of the TIA `State` contract. Each key
|
||||
* maps verbatim to a file name under `$rootDir`, so existing `.temp/*.json`
|
||||
* layouts are preserved exactly.
|
||||
*
|
||||
* The root directory is created lazily on first write — callers don't have
|
||||
* to pre-provision it, and reads against a missing directory simply return
|
||||
* `null` rather than throwing.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class FileState implements State
|
||||
final class FileState implements State
|
||||
{
|
||||
/**
|
||||
* Configured root. May not exist on disk yet; resolved + created on
|
||||
* the first write. Keeping the raw string lets the instance be built
|
||||
* before Pest's temp dir has been materialised.
|
||||
*/
|
||||
private string $rootDir;
|
||||
private readonly string $rootDir;
|
||||
|
||||
private ?string $resolvedRoot = null;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
@@ -57,8 +46,6 @@ final readonly class FileState implements State
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic rename — on POSIX filesystems this is a single-step
|
||||
// replacement, so concurrent readers never see a half-written file.
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
@@ -102,39 +89,36 @@ final readonly class FileState implements State
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (str_ends_with($path, '.tmp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path for `$key`. Not part of the interface — used by the
|
||||
* coverage merger and similar callers that need direct filesystem
|
||||
* access (e.g. `require` on a cached PHP file). Consumers that only
|
||||
* deal in bytes should go through `read()` / `write()`.
|
||||
*/
|
||||
public function pathFor(string $key): string
|
||||
{
|
||||
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved root if it exists already, otherwise `null`.
|
||||
* Used by read-side helpers so they don't eagerly create the directory
|
||||
* just to find nothing inside.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the root dir on demand. Returns false only when creation
|
||||
* fails and the directory still isn't there afterwards.
|
||||
*/
|
||||
private function ensureRoot(): bool
|
||||
{
|
||||
if (is_dir($this->rootDir)) {
|
||||
|
||||
+122
-414
@@ -4,91 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Captures environmental inputs that, when changed, may make the TIA graph
|
||||
* or its recorded results stale. The fingerprint is split into two buckets:
|
||||
*
|
||||
* - **structural** — describes what the graph's *edges* were recorded
|
||||
* against. If any of these drift (`composer.lock`, `composer.json`,
|
||||
* `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the
|
||||
* edges themselves are potentially wrong and the graph must rebuild
|
||||
* from scratch. `tests/TestCase.php` and `tests/Pest.php` are
|
||||
* intentionally NOT here — those are handled by per-test ancestor
|
||||
* linking (`Recorder::linkAncestorFiles`) and the Php watch pattern
|
||||
* respectively, which give precise invalidation rather than a wholesale
|
||||
* rebuild.
|
||||
* - **environmental** — describes the *runtime* the results were captured
|
||||
* on (PHP minor, extension set). Drift here means the edges are still
|
||||
* trustworthy, but the cached per-test results (pass/fail/time) may
|
||||
* not reproduce on this machine. Tia's handler drops the branch's
|
||||
* results + coverage cache and re-runs to freshen them, rather than
|
||||
* re-recording from scratch. Pest's own version is intentionally NOT
|
||||
* here — `composer.lock`'s structural hash already moves whenever the
|
||||
* installed Pest version changes.
|
||||
*
|
||||
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
||||
* rebuilt on first load; the schema bump in the structural bucket takes
|
||||
* care of that automatically.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
// Bump this whenever the set of inputs or the hash algorithm changes,
|
||||
// so older graphs are invalidated automatically.
|
||||
//
|
||||
// v5: ChangedFiles now hashes via `ContentHash` (normalises PHP
|
||||
// tokens + Blade whitespace/comments) instead of raw bytes.
|
||||
// Old graphs' run-tree hashes are incompatible and must be
|
||||
// rebuilt.
|
||||
// v6: Graph gained per-test table edges (`$testTables`) powering
|
||||
// surgical migration invalidation. Worker partial shape
|
||||
// changed to `{files, tables}`. Old graphs have no table
|
||||
// coverage, which would leave every DB test invalidated by
|
||||
// any migration change — force a rebuild so the new edges
|
||||
// are populated.
|
||||
// v7: Graph gained per-test Inertia page-component edges
|
||||
// (`$testInertiaComponents`) for surgical page-file
|
||||
// invalidation. Worker partial now includes an `inertia`
|
||||
// section. Old graphs have no component edges; without a
|
||||
// rebuild Vue/React page edits would fall through to the
|
||||
// broad watch pattern even when precise matching could have
|
||||
// worked.
|
||||
// v8: Graph gained `$jsFileToComponents` — reverse dependency
|
||||
// map computed at record time from Vite's module graph (or
|
||||
// the PHP fallback) so shared components / layouts /
|
||||
// composables invalidate the specific pages they're used
|
||||
// by, not every browser test.
|
||||
// v9: `ContentHash` now normalises JS/TS/Vue/Svelte comments +
|
||||
// whitespace. Old graphs' run-tree hashes for those files
|
||||
// were raw-byte; mixing formats would flag every JS file as
|
||||
// changed on first run.
|
||||
// v10: `vite.config.*` hashed into the structural bucket. A
|
||||
// Vite config change reshapes the module dependency graph
|
||||
// that `JsModuleGraph` records; without a graph rebuild
|
||||
// the stored `$jsFileToComponents` map silently goes stale.
|
||||
// v11: `composer.json` added (autoload-dev / extra discovery
|
||||
// changes). `tests/TestCase.php` and `tests/Pest.php` are
|
||||
// intentionally NOT fingerprinted — they're handled by the
|
||||
// watch pattern + `Recorder::linkAncestorFiles` reflection
|
||||
// walk, which gives precise per-test invalidation rather
|
||||
// than a wholesale rebuild that trashes the entire graph.
|
||||
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
|
||||
// now hash via `ContentHash::of()` so cosmetic comment +
|
||||
// whitespace edits don't fire rebuilds. composer.json and
|
||||
// composer.lock hash a behavioural subset — description,
|
||||
// keywords, scripts, authors, install timestamps, dist
|
||||
// URLs etc. no longer drift the structural fingerprint.
|
||||
// v13: Environment files (`.env`, `.env.testing`, local variants)
|
||||
// are included in the environmental bucket. They are commonly
|
||||
// git-ignored, so watch patterns alone cannot reliably notice
|
||||
// edits; a drift drops cached results and re-executes the suite.
|
||||
private const int SCHEMA_VERSION = 13;
|
||||
private const int SCHEMA_VERSION = 18;
|
||||
|
||||
/**
|
||||
* @var array<int, class-string<Lockfile>>
|
||||
*/
|
||||
private const array LOCKFILES = [
|
||||
Lockfiles\PackageLock::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @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
|
||||
@@ -96,51 +32,27 @@ final readonly class Fingerprint
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
// `composer.lock` hashed against a *behavioural*
|
||||
// subset (per-package version + reference + autoload +
|
||||
// extra). Skips per-package install timestamps, dist
|
||||
// URLs, support links, descriptions — none of which
|
||||
// affect what code runs.
|
||||
'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
// Pest's generated classes bake the code-generation logic
|
||||
// in — if TestCaseFactory changes (new attribute, different
|
||||
// method signature, etc.) every previously-recorded edge is
|
||||
// stale. Hashing via `ContentHash::of()` so cosmetic edits
|
||||
// (comments, formatting) don't drift the fingerprint.
|
||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
// `vite.config.*` reshapes the module graph
|
||||
// `JsModuleGraph` records at the next `--tia` run; if
|
||||
// the config drifts without a rebuild, the stored
|
||||
// `$jsFileToComponents` map is silently stale.
|
||||
// `viteConfigHash` itself uses `ContentHash::of()` so
|
||||
// a comment-only edit to vite.config doesn't rebuild.
|
||||
'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),
|
||||
// `composer.json` hashed against a behavioural subset:
|
||||
// autoload(-dev), require(-dev), extra (Laravel
|
||||
// package discovery), repositories, minimum-stability,
|
||||
// and the platform / allow-plugins entries from
|
||||
// `config`. Cosmetic fields (description, keywords,
|
||||
// scripts, authors, funding, support) are excluded.
|
||||
'composer_json' => self::composerJsonHash($projectRoot),
|
||||
// 'package_json' => self::packageJsonHash($projectRoot),
|
||||
'package_lock' => self::packageLockHash($projectRoot),
|
||||
'js_config' => self::jsConfigHash($projectRoot),
|
||||
// 'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'environmental' => [
|
||||
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
||||
// almost never matches a dev's Herd/Homebrew install, and
|
||||
// the patch rarely changes anything test-visible.
|
||||
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
||||
'extensions' => self::extensionsFingerprint($projectRoot),
|
||||
'env_files' => self::envFilesHash($projectRoot),
|
||||
'php_minor' => PHP_MAJOR_VERSION,
|
||||
|
||||
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
||||
// 'env_files' => self::envFilesHash($projectRoot),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the structural buckets match. Drift here means the edges
|
||||
* are potentially wrong; caller should discard the graph and rebuild.
|
||||
*
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
*/
|
||||
@@ -156,68 +68,54 @@ final readonly class Fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of structural field names that drifted between
|
||||
* the stored and current fingerprints. Empty list = no drift.
|
||||
* Caller uses this to tell the user *why* the graph rebuilt — a
|
||||
* generic "graph outdated" message leaves people staring at
|
||||
* unrelated diffs.
|
||||
*
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
*/
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of field names that drifted between the stored and
|
||||
* current environmental fingerprints. Empty list = no drift. Caller
|
||||
* uses this to print a human-readable warning and to decide whether
|
||||
* per-test results should be dropped (any drift → yes).
|
||||
*
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -245,11 +143,6 @@ final readonly class Fingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `$fingerprint[$key]` as an `array<string, mixed>` if it exists
|
||||
* and is an array, otherwise empty. Legacy flat-shape fingerprints
|
||||
* (schema ≤ 3) return empty here, which makes `structuralMatches` fail
|
||||
* and the caller rebuild — the clean migration path.
|
||||
*
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -272,18 +165,15 @@ final readonly class Fingerprint
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
|
||||
* at the project root. Most projects have exactly one; we accept
|
||||
* any of the five recognised extensions without assuming which
|
||||
* the user picked. Returns null when no config file exists —
|
||||
* treated as "no Vite project" by the matcher, no drift.
|
||||
*/
|
||||
private static function viteConfigHash(string $projectRoot): ?string
|
||||
{
|
||||
$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) {
|
||||
@@ -294,225 +184,121 @@ final readonly class Fingerprint
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes environment files that can globally alter app boot behaviour.
|
||||
* These files are often git-ignored, so they cannot rely on changed-file
|
||||
* detection. The environmental bucket keeps graph edges while forcing all
|
||||
* cached results to refresh after an env edit.
|
||||
*/
|
||||
private static function envFilesHash(string $projectRoot): ?string
|
||||
private static function jsConfigHash(string $projectRoot): ?string
|
||||
{
|
||||
$paths = [
|
||||
$projectRoot.'/.env',
|
||||
$projectRoot.'/.env.testing',
|
||||
$projectRoot.'/.env.local',
|
||||
];
|
||||
|
||||
$localVariants = glob($projectRoot.'/.env.*.local');
|
||||
|
||||
if (is_array($localVariants)) {
|
||||
foreach ($localVariants as $path) {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (isset($seen[$path])) {
|
||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$path] = true;
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = basename($path).':'.hash('xxh128', $contents);
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($parts);
|
||||
|
||||
return hash('xxh128', implode("\n", $parts));
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavioural subset of `composer.json`. Keeps the keys that
|
||||
* actually move test outcomes (autoload, require, extra,
|
||||
* repositories, minimum-stability, platform / allow-plugins
|
||||
* config) and drops cosmetic ones (description, keywords,
|
||||
* scripts, authors, funding, homepage, support). Falls back to
|
||||
* a raw hash on parse errors so any change still rebuilds.
|
||||
*/
|
||||
private static function composerJsonHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.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;
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavioural subset of `composer.lock`. For every package in
|
||||
* `packages` and `packages-dev`, keeps version + dist/source
|
||||
* reference (commit SHA — catches dev-branch updates that don't
|
||||
* bump the version string) + autoload(-dev) + extra (Laravel
|
||||
* package discovery). Drops install timestamps, dist URLs,
|
||||
* support links, descriptions, etc. — none of which change what
|
||||
* code runs.
|
||||
*/
|
||||
private static function composerLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.lock';
|
||||
return self::trackedHash($projectRoot, 'composer.lock');
|
||||
}
|
||||
|
||||
private static function packageLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function lockfileHash(string $path, string $name): ?string
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
$contents = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
foreach (self::LOCKFILES as $class) {
|
||||
$handler = new $class;
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$relevant = [
|
||||
'platform' => $data['platform'] ?? null,
|
||||
'platform-dev' => $data['platform-dev'] ?? null,
|
||||
];
|
||||
|
||||
foreach (['packages', 'packages-dev'] as $section) {
|
||||
if (! isset($data[$section])) {
|
||||
if (! $handler->applies($name)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($data[$section])) {
|
||||
continue;
|
||||
}
|
||||
$packages = [];
|
||||
|
||||
foreach ($data[$section] as $package) {
|
||||
if (! is_array($package)) {
|
||||
continue;
|
||||
}
|
||||
$fingerprint = $handler->fingerprint($contents);
|
||||
|
||||
$name = $package['name'] ?? null;
|
||||
|
||||
if (! is_string($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$packages[$name] = [
|
||||
'version' => $package['version'] ?? null,
|
||||
'reference' => self::lockReference($package),
|
||||
'autoload' => $package['autoload'] ?? null,
|
||||
'autoload-dev' => $package['autoload-dev'] ?? null,
|
||||
'extra' => $package['extra'] ?? null,
|
||||
];
|
||||
if ($fingerprint !== null) {
|
||||
return $fingerprint;
|
||||
}
|
||||
|
||||
ksort($packages);
|
||||
$relevant[$section] = $packages;
|
||||
break;
|
||||
}
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
return hash('xxh128', $contents);
|
||||
}
|
||||
|
||||
$json = json_encode($relevant);
|
||||
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
||||
{
|
||||
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
return self::hashIfExists($projectRoot.'/'.$relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $package
|
||||
* 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 lockReference(array $package): ?string
|
||||
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
|
||||
{
|
||||
$dist = is_array($package['dist'] ?? null) ? $package['dist'] : [];
|
||||
$source = is_array($package['source'] ?? null) ? $package['source'] : [];
|
||||
|
||||
$reference = $dist['reference'] ?? $source['reference'] ?? null;
|
||||
|
||||
return is_string($reference) ? $reference : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sorts associative arrays by key so semantically
|
||||
* equivalent JSON produces the same hash regardless of key
|
||||
* ordering. Lists (numeric arrays) keep their order — they're
|
||||
* meaningful in `repositories`, `autoload.files`, etc.
|
||||
*/
|
||||
private static function sortRecursively(mixed &$value): void
|
||||
{
|
||||
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
|
||||
@@ -536,82 +322,4 @@ final readonly class Fingerprint
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic hash of the extensions the project actually depends on —
|
||||
* the `ext-*` entries in composer.json's `require` / `require-dev`. An
|
||||
* incidental extension loaded on the developer's machine (or on CI) but
|
||||
* not declared as a dependency can't affect correctness of the test
|
||||
* suite, so we ignore it here to keep the drift signal quiet.
|
||||
*
|
||||
* Declared extensions that aren't currently loaded record as `missing`,
|
||||
* which is itself a drift signal worth surfacing.
|
||||
*/
|
||||
private static function extensionsFingerprint(string $projectRoot): string
|
||||
{
|
||||
$extensions = self::declaredExtensions($projectRoot);
|
||||
|
||||
if ($extensions === []) {
|
||||
return hash('xxh128', '');
|
||||
}
|
||||
|
||||
sort($extensions);
|
||||
|
||||
$parts = [];
|
||||
|
||||
foreach ($extensions as $name) {
|
||||
$version = phpversion($name);
|
||||
$parts[] = $name.'@'.($version === false ? 'missing' : $version);
|
||||
}
|
||||
|
||||
return hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension names (without the `ext-` prefix) that appear as keys under
|
||||
* `require` or `require-dev` in the project's composer.json. Returns
|
||||
* an empty list when composer.json is missing / unreadable / malformed,
|
||||
* so the environmental fingerprint stays stable in those cases rather
|
||||
* than flapping.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function declaredExtensions(string $projectRoot): array
|
||||
{
|
||||
$path = $projectRoot.'/composer.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$extensions = [];
|
||||
|
||||
foreach (['require', 'require-dev'] as $section) {
|
||||
$packages = $data[$section] ?? null;
|
||||
|
||||
if (! is_array($packages)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_keys($packages) as $package) {
|
||||
if (is_string($package) && str_starts_with($package, 'ext-')) {
|
||||
$extensions[] = substr($package, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($extensions));
|
||||
}
|
||||
}
|
||||
|
||||
+563
-513
File diff suppressed because it is too large
Load Diff
@@ -1,222 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Inertia-aware collaborator: during record mode, attributes every
|
||||
* Inertia component the test server-side renders to the currently-
|
||||
* running test file.
|
||||
*
|
||||
* Why this exists: a change to `resources/js/Pages/Users/Show.vue`
|
||||
* should only invalidate tests that actually rendered `Users/Show`.
|
||||
* The Laravel `WatchDefaults\Inertia` glob is a broad fallback — fine
|
||||
* for brand-new pages, but noisy once the graph has real data. With
|
||||
* this armed, each test's recorded edge set grows to include the
|
||||
* component names it returned through `Inertia::render()`, and
|
||||
* subsequent replay intersects page-file changes against that set.
|
||||
*
|
||||
* Mechanism: listen for `Illuminate\Foundation\Http\Events\RequestHandled`
|
||||
* on Laravel's event dispatcher. Inertia responses are identifiable by
|
||||
* either an `X-Inertia` header (XHR / JSON shape) or a `data-page`
|
||||
* attribute on the root `<div id="app">` (full HTML shape). Both carry
|
||||
* the component name in a structured payload we can parse cheaply.
|
||||
*
|
||||
* Same dep-free handshake as `BladeEdges` / `TableTracker`: string
|
||||
* class lookup + method-capability probes so Pest's `require` stays
|
||||
* Laravel-free.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class InertiaEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
/**
|
||||
* Event class name used as the listener key. Stored *without* a
|
||||
* leading backslash because Laravel's `Dispatcher` keys
|
||||
* `$listeners[$eventName]` by the literal string passed to
|
||||
* `listen()`, and looks up incoming events by their PHP-class
|
||||
* name (`get_class($event)`), which never has a leading
|
||||
* backslash. A `\Illuminate\…` key would silently never match.
|
||||
*/
|
||||
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent across per-test
|
||||
* `setUp()` calls. Laravel reuses the same app across tests in
|
||||
* most configurations — without this guard we'd stack one
|
||||
* listener per test.
|
||||
*/
|
||||
private const string MARKER = 'pest.tia.inertia-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
{
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$containerClass = self::CONTAINER_CLASS;
|
||||
|
||||
if (! class_exists($containerClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var object $app */
|
||||
$app = $containerClass::getInstance();
|
||||
|
||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $app->bound('events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$app->instance(self::MARKER, true);
|
||||
|
||||
/** @var object $events */
|
||||
$events = $app->make('events');
|
||||
|
||||
if (! method_exists($events, 'listen')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
||||
if (! property_exists($event, 'response')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var mixed $response */
|
||||
$response = $event->response;
|
||||
|
||||
if (! is_object($response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component = self::extractComponent($response);
|
||||
|
||||
if ($component !== null) {
|
||||
$recorder->linkInertiaComponent($component);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the Inertia component name out of a Laravel response,
|
||||
* handling both XHR (`X-Inertia` + JSON body) and full HTML
|
||||
* (`<div id="app" data-page="…">`) shapes. Returns null for any
|
||||
* non-Inertia response so the caller can ignore it cheaply.
|
||||
*/
|
||||
private static function extractComponent(object $response): ?string
|
||||
{
|
||||
// XHR path: Inertia sets an `X-Inertia: true` header and the
|
||||
// body is JSON with a `component` key.
|
||||
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'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial-load HTML path. Inertia ships two shapes here and
|
||||
// we honour both:
|
||||
//
|
||||
// 1. SSR-safe script tag — `<script data-page="app"
|
||||
// type="application/json">{…JSON…}</script>`. The
|
||||
// Laravel React starter kit (and modern Inertia-React)
|
||||
// use this so the JSON survives server-rendered
|
||||
// hydration without HTML-encoding the payload into an
|
||||
// attribute. The `data-page="app"` *attribute value* is
|
||||
// the literal string `"app"` — only the tag *body*
|
||||
// carries the page JSON.
|
||||
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
|
||||
// Inertia-Vue and Inertia-React still emit this. Here
|
||||
// `data-page` IS the JSON, HTML-entity-encoded.
|
||||
//
|
||||
// Try the script-tag shape first; if the response uses it,
|
||||
// the classic regex would also see a `data-page="app"` token
|
||||
// and try to JSON-decode the literal string `"app"`.
|
||||
$content = self::readContent($response);
|
||||
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lookahead pair handles arbitrary attribute order on the
|
||||
// `<script>` tag.
|
||||
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]));
|
||||
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
// Classic: only accept a value that looks like a JSON object
|
||||
// (`{…}`). Avoids matching the script-tag form's
|
||||
// `data-page="app"` attribute when both shapes coexist.
|
||||
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 null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Inertia page JSON blob and returns the `component`
|
||||
* field if it's a non-empty string. Used by both the script-tag
|
||||
* and the `data-page`-attribute paths so the success criteria are
|
||||
* identical.
|
||||
*/
|
||||
private static function componentFromJson(string $json): ?string
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (is_array($decoded)
|
||||
&& isset($decoded['component'])
|
||||
&& is_string($decoded['component'])
|
||||
&& $decoded['component'] !== '') {
|
||||
return $decoded['component'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function readContent(object $response): ?string
|
||||
{
|
||||
if (! method_exists($response, 'getContent')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var mixed $content */
|
||||
$content = $response->getContent();
|
||||
|
||||
return is_string($content) ? $content : null;
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Fallback parser for ES module imports under `resources/js/`.
|
||||
*
|
||||
* Used only when the Node helper (`bin/pest-tia-vite-deps.mjs`) is
|
||||
* unavailable — typically when Node isn't on `PATH` or the user's
|
||||
* `vite.config.*` can't be loaded. Pure PHP, so it degrades
|
||||
* gracefully on locked-down environments but cannot match the
|
||||
* full-fidelity Vite resolver.
|
||||
*
|
||||
* Known limits (intentional — preserving correctness over precision):
|
||||
* - Only `@/` and `~/` aliases recognised (both resolve to
|
||||
* `resources/js/`, the community default). Custom aliases from
|
||||
* `vite.config.ts` are ignored; anything we can't resolve is
|
||||
* simply skipped and falls through to the watch-pattern safety
|
||||
* net.
|
||||
* - Dynamic imports with variable expressions
|
||||
* (`import(`./${name}`.vue)`) can't be resolved; the literal
|
||||
* prefix is ignored and the caller over-runs. Safe.
|
||||
* - Vue SFC `<script>` blocks parsed whole; imports inside
|
||||
* `<template>` blocks (rare but legal) are not scanned.
|
||||
*
|
||||
* Output shape mirrors the Node helper: project-relative source path
|
||||
* → sorted list of component names of pages that depend on it.
|
||||
*
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* Walks the project's pages directory (`resources/js/Pages` or its
|
||||
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
|
||||
* and, for each page, collects its transitive file imports. Returns
|
||||
* the inverted graph so callers can look up "what pages depend on
|
||||
* this shared file".
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the importable region of a file. For Vue SFCs, only the
|
||||
* `<script>` block is relevant for imports; ignoring the rest
|
||||
* avoids false-positive matches inside `<template>` attributes.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks out every `import … from '…'` / `import '…'` / `import('…')`
|
||||
* target. We strip line comments first so a commented-out import
|
||||
* doesn't bloat the dep set.
|
||||
*
|
||||
* @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));
|
||||
}
|
||||
|
||||
// Anything else is either a node_modules package or an
|
||||
// unrecognised alias — skip. The watch-pattern fallback
|
||||
// handles the safety-net case for non-matched paths.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports may omit the extension or point at a directory (index.vue,
|
||||
* index.ts). Probe the common targets in order.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,85 +8,112 @@ use Symfony\Component\Process\ExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Builds a reverse dependency map for the project's JS sources under
|
||||
* `resources/js/**` — for every source file, the list of Inertia page
|
||||
* components that transitively import it.
|
||||
*
|
||||
* Tries two resolvers in order:
|
||||
*
|
||||
* 1. **Node helper** (`bin/pest-tia-vite-deps.mjs`). Spins up a
|
||||
* headless Vite server in middleware mode, walks Vite's own
|
||||
* module graph for each page entry, and outputs JSON. Uses the
|
||||
* project's real `vite.config.*`, so aliases, plugins, and SFC
|
||||
* transformers produce the same graph Vite itself would use.
|
||||
*
|
||||
* 2. **PHP fallback** (`JsImportParser`). Regex-scans ES imports
|
||||
* and resolves `@/` / `~/` aliases manually. Strictly less
|
||||
* precise — anything it can't resolve is skipped, leaving the
|
||||
* caller to fall back to the broad watch pattern. Only kicks in
|
||||
* when the Node helper is unusable (no Node on PATH, no Vite
|
||||
* installed, vite.config fails to load).
|
||||
*
|
||||
* Callers invoke this at record time; results are persisted into the
|
||||
* graph so replay never re-runs the resolver. On stale-map detection
|
||||
* the callers decide whether to rebuild.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class JsModuleGraph
|
||||
{
|
||||
private const int NODE_TIMEOUT_SECONDS = 25;
|
||||
private const int NODE_TIMEOUT_SECONDS = 180;
|
||||
|
||||
private const string CACHE_FILE = 'js-module-graph.cache.json';
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>> project-relative source path → sorted list of page component names
|
||||
* @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>>
|
||||
*/
|
||||
public static function build(string $projectRoot): array
|
||||
{
|
||||
$viaNode = self::tryNodeHelper($projectRoot);
|
||||
$result = self::resolve($projectRoot);
|
||||
|
||||
if ($viaNode !== null) {
|
||||
return $viaNode;
|
||||
}
|
||||
|
||||
return JsImportParser::parse($projectRoot);
|
||||
return $result ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant — only runs the Node helper, never falls back to
|
||||
* the PHP parser. Returns null when Node isn't available or Vite
|
||||
* won't load.
|
||||
*
|
||||
* Used at replay time when we need to *trust a negative result*
|
||||
* (i.e., "no page imports this file, so it's orphan, safe to
|
||||
* skip"). The PHP fallback is conservative on positives but can
|
||||
* miss imports that rely on custom aliases or plugins — negative
|
||||
* results from it cannot be trusted for orphan pruning.
|
||||
*
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
public static function buildStrict(string $projectRoot): ?array
|
||||
{
|
||||
return self::tryNodeHelper($projectRoot);
|
||||
return self::resolve($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the project looks like a Vite + Node project we can
|
||||
* ask for a module graph. Gate for callers that want to skip the
|
||||
* resolver entirely on non-Vite apps.
|
||||
*/
|
||||
public static function isApplicable(string $projectRoot): bool
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
|
||||
// starter kit (`pages/`) conventions are accepted — projects
|
||||
// running on a case-sensitive filesystem (Linux CI) get
|
||||
// exactly one of the two, and we shouldn't refuse to walk the
|
||||
// graph based on which one it picks.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -97,13 +124,48 @@ final class JsModuleGraph
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function tryNodeHelper(string $projectRoot): ?array
|
||||
private static function resolve(string $projectRoot): ?array
|
||||
{
|
||||
$fingerprint = self::fingerprint($projectRoot);
|
||||
|
||||
if ($fingerprint !== null) {
|
||||
$cached = self::readCache($projectRoot, $fingerprint);
|
||||
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$process = self::buildNodeProcess($projectRoot);
|
||||
|
||||
if (! $process instanceof Process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = self::parseNodeOutput($process->getOutput());
|
||||
|
||||
if ($result !== null && $fingerprint !== null) {
|
||||
self::writeCache($projectRoot, $fingerprint, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function buildNodeProcess(string $projectRoot): ?Process
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
||||
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||
|
||||
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -119,30 +181,19 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tell the Node helper which casing this project uses for its
|
||||
// pages directory. The helper defaults to `resources/js/Pages`;
|
||||
// the Laravel React starter ships lowercase `resources/js/pages`,
|
||||
// and on a case-sensitive filesystem the helper would otherwise
|
||||
// walk a non-existent directory and emit an empty module graph.
|
||||
$env = [];
|
||||
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
||||
$env['TIA_VITE_PAGES_DIR'] = $candidate;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
|
||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
|
||||
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function parseNodeOutput(string $output): ?array
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($process->getOutput(), true);
|
||||
$decoded = json_decode($output, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
@@ -176,14 +227,167 @@ final class JsModuleGraph
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
private static function fingerprint(string $projectRoot): ?string
|
||||
{
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
||||
return true;
|
||||
$parts = [];
|
||||
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stat = @stat($path);
|
||||
$bytes = @file_get_contents($path);
|
||||
|
||||
$parts[] = 'config:'.$name
|
||||
.':'.($stat === false ? '0' : (string) $stat['mtime'])
|
||||
.':'.($stat === false ? '0' : (string) $stat['size'])
|
||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
||||
}
|
||||
|
||||
return false;
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$override = getenv('TIA_VITE_PAGES_DIR');
|
||||
|
||||
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(
|
||||
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
);
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $file->getPathname()
|
||||
.':'.$file->getSize()
|
||||
.':'.$file->getMTime();
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function readCache(string $projectRoot, string $fingerprint): ?array
|
||||
{
|
||||
$path = self::cachePath($projectRoot);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graph = $decoded['graph'] ?? null;
|
||||
|
||||
if (! is_array($graph)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($graph as $key => $value) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($value as $name) {
|
||||
if (is_string($name) && $name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$out[$key] = $names;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $graph
|
||||
*/
|
||||
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
|
||||
{
|
||||
$path = self::cachePath($projectRoot);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'fingerprint' => $fingerprint,
|
||||
'graph' => $graph,
|
||||
]);
|
||||
|
||||
if ($payload === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
|
||||
|
||||
if (@file_put_contents($tmp, $payload) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
private static function cachePath(string $projectRoot): string
|
||||
{
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Lockfiles;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class PackageLock implements Lockfile
|
||||
{
|
||||
public function applies(string $filename): bool
|
||||
{
|
||||
return $filename === 'package-lock.json';
|
||||
}
|
||||
|
||||
public function fingerprint(string $contents): ?string
|
||||
{
|
||||
$data = json_decode($contents, true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$packages = $data['packages'];
|
||||
|
||||
$platformPaths = [];
|
||||
|
||||
foreach ($packages as $path => $meta) {
|
||||
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
|
||||
$platformPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach ($packages as $path => $meta) {
|
||||
if (! is_string($path)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($meta)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isPlatformSpecific($meta)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
|
||||
continue;
|
||||
}
|
||||
$version = $meta['version'] ?? null;
|
||||
|
||||
$entries[$path] = is_string($version) ? $version : '';
|
||||
}
|
||||
|
||||
ksort($entries);
|
||||
|
||||
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $encoded === false ? null : hash('xxh128', $encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function isPlatformSpecific(array $meta): bool
|
||||
{
|
||||
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $platformPaths
|
||||
*/
|
||||
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
|
||||
{
|
||||
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
|
||||
}
|
||||
}
|
||||
+105
-589
@@ -8,130 +8,54 @@ use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Captures per-test file coverage using the PCOV driver.
|
||||
*
|
||||
* Acts as a singleton because PCOV has a single global collection state and
|
||||
* the recorder is wired into PHPUnit through two distinct subscribers
|
||||
* (`Prepared` / `Finished`) that must share context.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Recorder
|
||||
{
|
||||
/**
|
||||
* Test file currently being recorded, or `null` when idle.
|
||||
*/
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<absolute source file>.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestFiles = [];
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<lowercase table name>.
|
||||
* Populated by `TableTracker` from `DB::listen` callbacks; consumed
|
||||
* at record finalize to populate the graph's `$testTables` edges
|
||||
* that drive migration-change impact analysis.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestTables = [];
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<Inertia component name>.
|
||||
* Populated by `InertiaEdges` from Inertia responses observed at
|
||||
* request-handled time; consumed at record finalize to populate
|
||||
* the graph's per-test component edges that drive Vue / React
|
||||
* page-file impact analysis.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestInertiaComponents = [];
|
||||
|
||||
/**
|
||||
* Set of absolute test files whose class hierarchy uses one of
|
||||
* Laravel's database-resetting traits (`RefreshDatabase`,
|
||||
* `DatabaseMigrations`, `DatabaseTransactions`). Captured at
|
||||
* `beginTest` so the finalize path can augment their table edges
|
||||
* even when seeders / pre-test DML fired before `TableTracker`
|
||||
* armed.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
/** @var array<string, true> */
|
||||
private array $perTestUsesDatabase = [];
|
||||
|
||||
/**
|
||||
* Cached class → test file resolution.
|
||||
*
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
/** @var array<string, string|null> */
|
||||
private array $classFileCache = [];
|
||||
|
||||
/**
|
||||
* Cached class → "uses Laravel DB trait" introspection result.
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
/** @var array<string, bool> */
|
||||
private array $classUsesDatabaseCache = [];
|
||||
|
||||
/**
|
||||
* Reverse map of project-local source file → list of class /
|
||||
* interface / trait names declared in it. Built incrementally as
|
||||
* tests run and new classes get autoloaded; consumed by
|
||||
* `linkSourceDependencies()` so a test's covered file's
|
||||
* declared classes can be walked for their interfaces, traits,
|
||||
* and parents (which the coverage driver doesn't capture
|
||||
* because interface declarations and empty traits emit no
|
||||
* executable bytecode).
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private array $fileToClassNames = [];
|
||||
|
||||
/**
|
||||
* Names already folded into `$fileToClassNames`. Lets the
|
||||
* incremental refresher skip classes seen in a previous test.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $indexedClassNames = [];
|
||||
|
||||
/**
|
||||
* Cached "files this class transitively depends on (interfaces,
|
||||
* traits, parent chain, parents' interfaces and traits)" for
|
||||
* project-local class names. Avoids re-walking the same
|
||||
* hierarchy on every test that touches the same class.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private array $classDependencyCache = [];
|
||||
|
||||
/**
|
||||
* Cached test-file import resolution.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
private array $testImportFileCache = [];
|
||||
|
||||
/**
|
||||
* Included-file snapshot captured at the start of the current test.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $includedFilesAtTestStart = [];
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $captureCoverage = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
|
||||
private bool $driverAvailable = false;
|
||||
|
||||
private string $driver = 'none';
|
||||
|
||||
private ?SourceScope $sourceScope = null;
|
||||
|
||||
public function activate(): void
|
||||
{
|
||||
$this->active = true;
|
||||
$this->captureCoverage = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable per-test link tracking (tables, Inertia components, database
|
||||
* usage, rendered views) without driving pcov/xdebug — for runs where
|
||||
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
||||
*/
|
||||
public function activateLinkTracking(): void
|
||||
{
|
||||
$this->active = true;
|
||||
}
|
||||
@@ -148,15 +72,6 @@ final class Recorder
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
// Xdebug 3+ exposes the active mode set via `xdebug_info`,
|
||||
// so we can ask directly instead of probing with a
|
||||
// start/stop pair. The probe approach used to emit
|
||||
// E_WARNING when coverage mode was off; with monitoring
|
||||
// agents (Sentry, Bugsnag) hooked into the error
|
||||
// handler stack that warning could be reported as a
|
||||
// real error. `xdebug_info('mode')` is silent and
|
||||
// returns the active modes as a list, so a presence
|
||||
// check is enough.
|
||||
$modes = \xdebug_info('mode');
|
||||
|
||||
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
||||
@@ -171,16 +86,13 @@ 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()) {
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,25 +107,14 @@ final class Recorder
|
||||
}
|
||||
|
||||
$this->currentTestFile = $file;
|
||||
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
|
||||
|
||||
if ($this->classUsesDatabase($className)) {
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
// Walk the parent-class chain and link each ancestor's defining
|
||||
// file as a source dependency of this test. Captures the common
|
||||
// `tests/TestCase.php` case (where the user's base may be
|
||||
// trait-only and have no executable lines for the coverage
|
||||
// driver to pick up), and any deeper hierarchy. Vendor parents
|
||||
// are skipped — those are pinned by `composer.lock` and don't
|
||||
// need per-test edges. Same idea applies to traits used by the
|
||||
// ancestors: a trait's body executes when the test method
|
||||
// calls into it, so coverage already captures it; we only need
|
||||
// the explicit walk for ancestors whose own bodies might be
|
||||
// empty.
|
||||
$this->linkAncestorFiles($className);
|
||||
$this->linkImportedFiles($file);
|
||||
if (! $this->captureCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
@@ -222,65 +123,60 @@ final class Recorder
|
||||
return;
|
||||
}
|
||||
|
||||
// Xdebug
|
||||
\xdebug_start_code_coverage();
|
||||
}
|
||||
|
||||
public function endTest(): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
||||
if (! $this->active || $this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||
$this->currentTestFile = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\stop();
|
||||
|
||||
$scope = $this->sourceScope();
|
||||
$filesToCollectCoverageFor = [];
|
||||
|
||||
foreach (\pcov\waiting() as $file) {
|
||||
if (is_string($file) && $scope->contains($file)) {
|
||||
$filesToCollectCoverageFor[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\inclusive);
|
||||
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
// `true` resets Xdebug's internal buffer so the next `start()`
|
||||
// does not accumulate earlier tests' coverage into the current
|
||||
// one — otherwise the graph becomes progressively polluted.
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$scope = $this->sourceScope();
|
||||
|
||||
foreach (array_keys($data) as $file) {
|
||||
if (! $scope->contains($file)) {
|
||||
unset($data[$file]);
|
||||
}
|
||||
}
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
}
|
||||
|
||||
foreach (array_keys($data) as $sourceFile) {
|
||||
foreach ($coveredFiles as $sourceFile) {
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
foreach (AutoloadEdges::newProjectFiles(
|
||||
$this->includedFilesAtTestStart,
|
||||
AutoloadEdges::snapshot(),
|
||||
TestSuite::getInstance()->rootPath,
|
||||
$this->currentTestFile,
|
||||
) as $sourceFile) {
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
// Walk each covered class's interfaces / traits / parent chain
|
||||
// and link those files explicitly. Interface declarations have
|
||||
// no executable bytecode, so coverage drivers never emit lines
|
||||
// for them — without this walk, a signature change to an
|
||||
// interface like `Viewable` would leave the cached results of
|
||||
// every test that exercises an implementing class stale,
|
||||
// because the interface file never enters the graph through
|
||||
// the coverage path.
|
||||
$this->linkSourceDependencies(array_keys($data));
|
||||
|
||||
$this->currentTestFile = null;
|
||||
$this->includedFilesAtTestStart = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an extra source-file dependency for the currently-running
|
||||
* test. Used by collaborators that capture edges the coverage driver
|
||||
* cannot see — Blade templates rendered through Laravel's view
|
||||
* factory are the motivating case (their `.blade.php` source never
|
||||
* executes directly; a cached compiled PHP file does). No-op when
|
||||
* the recorder is inactive or no test is in flight, so callers can
|
||||
* fire it unconditionally from app-level hooks.
|
||||
*/
|
||||
public function linkSource(string $sourceFile): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@@ -298,369 +194,6 @@ final class Recorder
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records source dependencies for a specific test file. Used for edges
|
||||
* captured before `Prepared` has opened the normal per-test recorder window.
|
||||
*
|
||||
* @param iterable<int, string> $sourceFiles
|
||||
*/
|
||||
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($testFile === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($sourceFiles as $sourceFile) {
|
||||
if ($sourceFile === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->perTestFiles[$testFile][$sourceFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For each project-local source file the coverage driver
|
||||
* captured for this test, finds the classes / interfaces / traits
|
||||
* declared in it and links every file in their declarative
|
||||
* hierarchy: implemented interfaces (transitive), used traits,
|
||||
* and parent classes (with their own interfaces and traits).
|
||||
*
|
||||
* Coverage drivers only record executable lines, so an interface
|
||||
* signature change (e.g. adding a return type to a `Viewable`
|
||||
* method) never registers — the interface file has no bytecode
|
||||
* to instrument. Without this walk, every class implementing the
|
||||
* interface would silently keep its stale cached result through
|
||||
* the change, even though `--parallel` (no TIA) catches the
|
||||
* incompatibility immediately.
|
||||
*
|
||||
* @param array<int, string> $coveredFiles absolute paths from coverage
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally folds every project-local class / interface /
|
||||
* trait declared since the last refresh into `$fileToClassNames`.
|
||||
* PHP only ever appends to its declared-symbol lists (classes
|
||||
* never get unloaded), so iterating from `$indexedClassNames`'s
|
||||
* cardinality forward is sufficient — and over a long suite this
|
||||
* is dominated by the first test, since most classes are loaded
|
||||
* by then.
|
||||
*/
|
||||
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;
|
||||
|
||||
// Names came directly from `get_declared_*`, so the
|
||||
// class/interface/trait is guaranteed loaded — but
|
||||
// `class_exists($name, false)` (no autoload) keeps the
|
||||
// string narrowed to `class-string` for static analysis
|
||||
// and the `ReflectionClass` constructor stays in its
|
||||
// documented happy path.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the project-local files the named class declaratively
|
||||
* depends on: implemented interfaces (transitive), used traits,
|
||||
* and the entire parent chain (each with their own interfaces
|
||||
* and traits). Cached per class because the answer is invariant
|
||||
* across a single process.
|
||||
*
|
||||
* @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;
|
||||
};
|
||||
|
||||
// `getInterfaceNames()` is transitive — it returns interfaces
|
||||
// from parent classes and parent interfaces too — so a single
|
||||
// pass covers the whole interface graph.
|
||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
||||
$linkSymbol($iname);
|
||||
}
|
||||
|
||||
// Direct + ancestor traits. `getTraitNames()` doesn't recurse
|
||||
// into traits-using-traits, but that's a rare pattern in
|
||||
// application code; if a project genuinely needs it, the
|
||||
// coverage driver will pick up the executed bytecode of the
|
||||
// outer trait and the dependency walk runs against the
|
||||
// resulting class anyway.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records every project-local ancestor class's defining file as a
|
||||
* source dependency of the currently-running test. PCOV / Xdebug
|
||||
* record *executable lines* — a base class whose body is just
|
||||
* `class TestCase extends BaseTestCase { use CreatesApplication; }`
|
||||
* has no executable bytecode of its own, so the driver doesn't
|
||||
* emit a line for it and it never enters the graph through the
|
||||
* usual coverage path. This walk fills that gap by asking
|
||||
* reflection for each parent's file and linking it explicitly.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Links project-local classes imported by the test file. This catches
|
||||
* declaration-only support classes / enums / interfaces that may never emit
|
||||
* executable coverage lines, and avoids relying on global autoload timing.
|
||||
*/
|
||||
private function linkImportedFiles(string $testFile): void
|
||||
{
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->importedFilesFor($testFile) as $file) {
|
||||
$this->perTestFiles[$this->currentTestFile][$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function importedFilesFor(string $testFile): array
|
||||
{
|
||||
if (array_key_exists($testFile, $this->testImportFileCache)) {
|
||||
return $this->testImportFileCache[$testFile];
|
||||
}
|
||||
|
||||
$source = @file_get_contents($testFile);
|
||||
if ($source === false) {
|
||||
return $this->testImportFileCache[$testFile] = [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
foreach ($this->importedClassNames($source) as $className) {
|
||||
$file = $this->findAutoloadFile($className);
|
||||
|
||||
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
$files[$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->testImportFileCache[$testFile] = array_keys($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function importedClassNames(string $source): array
|
||||
{
|
||||
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($matches[1] as $import) {
|
||||
$import = trim($import);
|
||||
|
||||
if ($import === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$open = strpos($import, '{');
|
||||
$close = strrpos($import, '}');
|
||||
|
||||
if ($open !== false && $close !== false && $close > $open) {
|
||||
$prefix = trim(trim(substr($import, 0, $open)), '\\');
|
||||
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
|
||||
|
||||
foreach ($items as $item) {
|
||||
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
|
||||
|
||||
if ($class !== null) {
|
||||
$classes[$class] = true;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$class = $this->normaliseImportedClass($import);
|
||||
|
||||
if ($class !== null) {
|
||||
$classes[$class] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($classes);
|
||||
}
|
||||
|
||||
private function normaliseImportedClass(string $import): ?string
|
||||
{
|
||||
$import = trim(trim($import), '\\');
|
||||
|
||||
if ($import === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+as\s+/i', $import);
|
||||
if ($parts === false || $parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$class = trim(trim($parts[0]), '\\');
|
||||
|
||||
return $class === '' ? null : $class;
|
||||
}
|
||||
|
||||
private function findAutoloadFile(string $className): ?string
|
||||
{
|
||||
foreach (spl_autoload_functions() as $loader) {
|
||||
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! method_exists($loader[0], 'findFile')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var mixed $file */
|
||||
$file = $loader[0]->findFile($className);
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$real = @realpath($file);
|
||||
|
||||
return $real === false ? $file : $real;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `$className` (or any of its ancestors) uses one of
|
||||
* Laravel's database-resetting traits. Walking up `getTraits()` is
|
||||
* necessary because Pest test classes are eval'd from the
|
||||
* generated `*.php` test file and the trait usually lives on a
|
||||
* shared `tests/TestCase.php` ancestor. Result is cached per class
|
||||
* — class hierarchies don't change within a process.
|
||||
*/
|
||||
private function classUsesDatabase(string $className): bool
|
||||
{
|
||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
||||
@@ -692,14 +225,6 @@ final class Recorder
|
||||
return $this->classUsesDatabaseCache[$className] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the currently-running test queried `$table`. Called
|
||||
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
||||
* reports; the table name has already been extracted by
|
||||
* `TableExtractor::fromSql()` so we just store it. No-op outside
|
||||
* a test window, so the callback is safe to leave armed across
|
||||
* setUp / tearDown boundaries.
|
||||
*/
|
||||
public function linkTable(string $table): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@@ -717,15 +242,6 @@ final class Recorder
|
||||
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the currently-running test server-side-rendered the
|
||||
* named Inertia component. The name is whatever
|
||||
* `Inertia::render($component, …)` was called with — typically a
|
||||
* slash-separated path like `Users/Show` that maps to
|
||||
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
|
||||
* so the underlying listener can stay armed without leaking
|
||||
* state between tests.
|
||||
*/
|
||||
public function linkInertiaComponent(string $component): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
@@ -743,9 +259,7 @@ final class Recorder
|
||||
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
||||
*/
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestFiles(): array
|
||||
{
|
||||
$out = [];
|
||||
@@ -757,9 +271,7 @@ final class Recorder
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → sorted list of table names.
|
||||
*/
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestTables(): array
|
||||
{
|
||||
$out = [];
|
||||
@@ -773,9 +285,7 @@ final class Recorder
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
|
||||
*/
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestInertiaComponents(): array
|
||||
{
|
||||
$out = [];
|
||||
@@ -789,9 +299,7 @@ final class Recorder
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true> absolute test file → true for tests using a Laravel DB-resetting trait.
|
||||
*/
|
||||
/** @return array<string, true> */
|
||||
public function perTestUsesDatabase(): array
|
||||
{
|
||||
return $this->perTestUsesDatabase;
|
||||
@@ -817,47 +325,56 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the file that *defines* the test class.
|
||||
*
|
||||
* Order of preference:
|
||||
* 1. Pest's generated `$__filename` static — the original `*.php` file
|
||||
* containing the `test()` calls (the eval'd class itself has no file).
|
||||
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
|
||||
* is intentionally more specific than `ReflectionMethod::getFileName()`
|
||||
* (which would return the *trait* file for methods brought in via
|
||||
* `uses SharedTestBehavior`).
|
||||
*/
|
||||
private function readPestFilename(string $className): ?string
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all captured state. Useful for long-running hosts (daemons,
|
||||
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
|
||||
* — without this, coverage from run N would bleed into run N+1.
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
private function filesWithExecutedLines(array $data): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $file => $lines) {
|
||||
if (! is_array($lines)) {
|
||||
continue;
|
||||
}
|
||||
$covered = [];
|
||||
foreach ($lines as $line => $count) {
|
||||
if (is_int($count) && $count > 0) {
|
||||
$covered[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if ($covered === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineKeys = array_keys($lines);
|
||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $file;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function sourceScope(): SourceScope
|
||||
{
|
||||
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentTestFile = null;
|
||||
@@ -867,9 +384,8 @@ final class Recorder
|
||||
$this->perTestUsesDatabase = [];
|
||||
$this->classFileCache = [];
|
||||
$this->classUsesDatabaseCache = [];
|
||||
$this->fileToClassNames = [];
|
||||
$this->indexedClassNames = [];
|
||||
$this->classDependencyCache = [];
|
||||
$this->sourceScope = null;
|
||||
$this->active = false;
|
||||
$this->captureCoverage = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,28 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* Collects per-test status + message during the run so the graph can persist
|
||||
* them for faithful replay. PHPUnit's own result cache discards messages
|
||||
* during serialisation — this collector retains them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ResultCollector
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
|
||||
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
private ?float $startTime = null;
|
||||
|
||||
public function testPrepared(string $testId): void
|
||||
public function testPrepared(string $testId, ?string $testFile = null): void
|
||||
{
|
||||
$this->currentTestId = $testId;
|
||||
$this->currentTestFile = $testFile;
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(0, '');
|
||||
$this->record(TestStatus::success());
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
@@ -43,7 +44,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(7, $message);
|
||||
$this->record(TestStatus::failure($message));
|
||||
}
|
||||
|
||||
public function testErrored(string $message): void
|
||||
@@ -52,7 +53,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(8, $message);
|
||||
$this->record(TestStatus::error($message));
|
||||
}
|
||||
|
||||
public function testSkipped(string $message): void
|
||||
@@ -61,7 +62,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(1, $message);
|
||||
$this->record(TestStatus::skipped($message));
|
||||
}
|
||||
|
||||
public function testIncomplete(string $message): void
|
||||
@@ -70,7 +71,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(2, $message);
|
||||
$this->record(TestStatus::incomplete($message));
|
||||
}
|
||||
|
||||
public function testRisky(string $message): void
|
||||
@@ -79,11 +80,11 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(5, $message);
|
||||
$this->record(TestStatus::risky($message));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
@@ -98,11 +99,7 @@ final class ResultCollector
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects externally-collected results (e.g. partials flushed by parallel
|
||||
* workers) into this collector so the parent can persist them in the same
|
||||
* snapshot pass as non-parallel runs.
|
||||
*
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions: int}> $results
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
|
||||
*/
|
||||
public function merge(array $results): void
|
||||
{
|
||||
@@ -115,21 +112,18 @@ final class ResultCollector
|
||||
{
|
||||
$this->results = [];
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the Finished subscriber after a test's outcome + assertion
|
||||
* events have all fired. Clears the "currently recording" pointer so
|
||||
* the next test's events don't get mis-attributed.
|
||||
*/
|
||||
public function finishTest(): void
|
||||
{
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function record(int $status, string $message): void
|
||||
private function record(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
@@ -139,17 +133,17 @@ final class ResultCollector
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 0.0;
|
||||
|
||||
// PHPUnit can fire more than one outcome event per test — the
|
||||
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
|
||||
// Last-wins semantics preserve the most specific status; the
|
||||
// existing assertion count (if any) survives the overwrite.
|
||||
$existing = $this->results[$this->currentTestId] ?? null;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'status' => $status->asInt(),
|
||||
'message' => $status->message(),
|
||||
'time' => $time,
|
||||
'assertions' => $existing['assertions'] ?? 0,
|
||||
];
|
||||
|
||||
if ($this->currentTestFile !== null) {
|
||||
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\TextUI\Configuration\Registry;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SourceScope
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $containsCache = [];
|
||||
|
||||
private const array TOP_LEVEL_NOISE = [
|
||||
'vendor',
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.github',
|
||||
'.pest',
|
||||
'.phpunit.cache',
|
||||
'.cache',
|
||||
];
|
||||
|
||||
private const array NESTED_NOISE = [
|
||||
'storage/framework',
|
||||
'storage/logs',
|
||||
'bootstrap/cache',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $includes Absolute, normalised directory paths.
|
||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $includes,
|
||||
private readonly array $excludes,
|
||||
) {}
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
{
|
||||
$phpunitIncludes = [];
|
||||
$phpunitExcludes = [];
|
||||
|
||||
try {
|
||||
$source = Registry::get()->source();
|
||||
|
||||
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);
|
||||
|
||||
$includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
|
||||
$excludes = array_values(array_unique([
|
||||
...$phpunitExcludes,
|
||||
...self::nestedNoiseDirs($projectRoot),
|
||||
]));
|
||||
|
||||
if ($includes === []) {
|
||||
$includes = [self::normalise($projectRoot)];
|
||||
}
|
||||
|
||||
return new self($includes, $excludes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
||||
*/
|
||||
public static function testPaths(): array
|
||||
{
|
||||
try {
|
||||
$suites = Registry::get()->testSuite();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($suites as $suite) {
|
||||
foreach ($suite->directories() as $directory) {
|
||||
$out[] = self::normalise($directory->path());
|
||||
}
|
||||
|
||||
foreach ($suite->files() as $file) {
|
||||
$out[] = self::normalise($file->path());
|
||||
}
|
||||
}
|
||||
|
||||
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 $this->containsCache[$absoluteFile] = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->includes as $included) {
|
||||
if ($this->startsWithDir($candidate, $included)) {
|
||||
return $this->containsCache[$absoluteFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->containsCache[$absoluteFile] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function topLevelProjectDirs(string $projectRoot): array
|
||||
{
|
||||
$entries = @scandir($projectRoot);
|
||||
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entry !== '' && $entry[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = self::normalise(@realpath($abs) ?: $abs);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function nestedNoiseDirs(string $projectRoot): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (self::NESTED_NOISE as $relative) {
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
$out[] = self::normalise(@realpath($abs) ?: $abs);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function normalise(string $path): string
|
||||
{
|
||||
return rtrim($path, '/\\');
|
||||
}
|
||||
|
||||
private function startsWithDir(string $candidate, string $dir): bool
|
||||
{
|
||||
if ($candidate === $dir) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
|
||||
}
|
||||
}
|
||||
+40
-64
@@ -5,36 +5,10 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Resolves TIA's on-disk state directory.
|
||||
*
|
||||
* Default location: `$HOME/.pest/tia/<project-key>/`. Keeping state in the
|
||||
* user's home directory (rather than under `vendor/pestphp/pest/`) means:
|
||||
*
|
||||
* - `composer install` / path-repo reinstalls don't wipe the graph.
|
||||
* - The state lives outside the project tree, so there is nothing for
|
||||
* users to gitignore or accidentally commit.
|
||||
* - Multiple worktrees of the same repo share one cache naturally.
|
||||
*
|
||||
* The project key is derived from the git origin URL when available — a
|
||||
* CI workflow running on `github.com/org/repo` and a developer's clone
|
||||
* of the same remote both compute the *same* key, which is what lets the
|
||||
* CI-uploaded baseline line up with the dev-side reader. When the project
|
||||
* is not in git, the key falls back to a hash of the absolute path so
|
||||
* unrelated projects on the same machine stay isolated.
|
||||
*
|
||||
* When no home directory is resolvable (`HOME` / `USERPROFILE` both
|
||||
* unset — the tests-tia sandboxes strip these deliberately, and some
|
||||
* locked-down CI environments do the same), state falls back to
|
||||
* `<projectRoot>/.pest/tia/`. That path is project-local but still
|
||||
* survives composer installs, so the degradation is graceful.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Storage
|
||||
{
|
||||
/**
|
||||
* Directory where TIA's State blobs live for `$projectRoot`.
|
||||
*/
|
||||
public static function tempDir(string $projectRoot): string
|
||||
{
|
||||
$home = self::homeDir();
|
||||
@@ -51,11 +25,46 @@ final class Storage
|
||||
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
|
||||
* Windows. Returns null if neither resolves to an existing
|
||||
* directory, in which case callers fall back to project-local state.
|
||||
*/
|
||||
public static function purge(string $projectRoot): void
|
||||
{
|
||||
$dir = self::tempDir($projectRoot);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::removeRecursive($dir);
|
||||
}
|
||||
|
||||
private static function removeRecursive(string $dir): void
|
||||
{
|
||||
$entries = @scandir($dir);
|
||||
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
self::removeRecursive($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
private static function homeDir(): ?string
|
||||
{
|
||||
foreach (['HOME', 'USERPROFILE'] as $key) {
|
||||
@@ -70,27 +79,7 @@ final class Storage
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder name for `$projectRoot` under `~/.pest/tia/`.
|
||||
*
|
||||
* Strategy — each step rules out a class of collision:
|
||||
*
|
||||
* 1. If the project has a git origin URL, use a **normalised** form
|
||||
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
|
||||
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
|
||||
* and `https://github.com/foo/bar` all collapse to
|
||||
* `github.com/foo/bar` — three developers cloning the same repo
|
||||
* by different transports share one cache, which is what we want.
|
||||
* 2. Otherwise, use the canonicalised absolute path (`realpath`).
|
||||
* Two unrelated `app/` checkouts under different parent folders
|
||||
* have different realpaths → different hashes → isolated.
|
||||
* 3. Hash the chosen input with sha256 and keep the first 16 hex
|
||||
* chars — 64 bits of entropy makes accidental collision
|
||||
* astronomically unlikely even across thousands of projects.
|
||||
* 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/`
|
||||
* is readable; the slug is cosmetic only, all isolation comes
|
||||
* from the hash.
|
||||
*
|
||||
* Result: `myapp-a1b2c3d4e5f67890`.
|
||||
*/
|
||||
private static function projectKey(string $projectRoot): string
|
||||
{
|
||||
@@ -105,12 +94,6 @@ final class Storage
|
||||
return $slug === '' ? $hash : $slug.'-'.$hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical git origin identity for `$projectRoot`, or null when
|
||||
* no origin URL can be parsed. The returned form is
|
||||
* `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git
|
||||
* protocol clones of the same remote produce the same value.
|
||||
*/
|
||||
private static function originIdentity(string $projectRoot): ?string
|
||||
{
|
||||
$url = self::rawOriginUrl($projectRoot);
|
||||
@@ -129,8 +112,6 @@ final class Storage
|
||||
return strtolower($m[1].'/'.$m[2]);
|
||||
}
|
||||
|
||||
// Unrecognised form — hash the raw URL so different inputs still
|
||||
// diverge, but lowercased so the only variance is intentional.
|
||||
return strtolower($url);
|
||||
}
|
||||
|
||||
@@ -155,11 +136,6 @@ final class Storage
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem-safe kebab of `$name`. Cosmetic only — used as a
|
||||
* human-readable prefix on the hash so `~/.pest/tia/` lists
|
||||
* recognisable folders.
|
||||
*/
|
||||
private static function slug(string $name): string
|
||||
{
|
||||
$slug = strtolower($name);
|
||||
|
||||
@@ -5,46 +5,19 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Extracts table names from SQL statements and migration PHP sources.
|
||||
*
|
||||
* Two callers, two methods:
|
||||
*
|
||||
* - `fromSql()` runs against query strings Laravel's `DB::listen`
|
||||
* hands us at record time. We only look at DML (`SELECT`, `INSERT`,
|
||||
* `UPDATE`, `DELETE`) because DDL emitted by `RefreshDatabase` in
|
||||
* `setUp()` is noise — we don't want every test to end up linked
|
||||
* to every migration's `CREATE TABLE`.
|
||||
* - `fromMigrationSource()` reads a migration file on disk at
|
||||
* replay time and pulls table names out of `Schema::` calls.
|
||||
* Used in two places:
|
||||
* 1. For every migration file reported as changed — what
|
||||
* tables does the current version of this file touch?
|
||||
* 2. For brand-new migration files that weren't in the graph
|
||||
* yet, so we never had a chance to observe their DDL.
|
||||
*
|
||||
* Regex isn't a parser. CTEs, subqueries, and raw `DB::statement()`
|
||||
* that reference tables only inside exotic syntax can slip through.
|
||||
* The direction of that error is under-attribution (a table the test
|
||||
* genuinely touches but we missed), so the safety net is to keep the
|
||||
* broad `database/migrations/**` watch pattern as a last resort for
|
||||
* files that produce an empty extraction.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TableExtractor
|
||||
{
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||
|
||||
/**
|
||||
* DML prefixes we accept. DDL (`CREATE`, `ALTER`, `DROP`,
|
||||
* `TRUNCATE`, `RENAME`) is deliberately excluded — those come
|
||||
* from migrations fired by `RefreshDatabase`, and capturing them
|
||||
* here would attribute every migration table to every test.
|
||||
* A single (optionally quoted) identifier segment.
|
||||
*/
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
||||
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||
|
||||
/**
|
||||
* @return list<string> Sorted, deduped table names referenced by the
|
||||
* SQL statement. Empty when the statement is
|
||||
* DDL, empty, or unparseable.
|
||||
*/
|
||||
public static function fromSql(string $sql): array
|
||||
{
|
||||
@@ -54,25 +27,15 @@ final class TableExtractor
|
||||
return [];
|
||||
}
|
||||
|
||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
||||
|
||||
$matched = false;
|
||||
foreach (self::DML_PREFIXES as $dml) {
|
||||
if (str_starts_with($prefix, $dml)) {
|
||||
$matched = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $matched) {
|
||||
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Match `from`, `into`, `update`, `join` and capture the
|
||||
// following identifier, tolerating the common quoting
|
||||
// styles: "double", `back`, [bracket], or bare.
|
||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
||||
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||
|
||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||
return [];
|
||||
@@ -80,14 +43,9 @@ final class TableExtractor
|
||||
|
||||
$tables = [];
|
||||
|
||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
||||
$name = $matches[1][$i] !== ''
|
||||
? $matches[1][$i]
|
||||
: ($matches[2][$i] !== ''
|
||||
? $matches[2][$i]
|
||||
: ($matches[3][$i] !== ''
|
||||
? $matches[3][$i]
|
||||
: $matches[4][$i]));
|
||||
foreach ($matches[1] as $qualified) {
|
||||
$name = self::unqualified($qualified);
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -106,85 +64,49 @@ final class TableExtractor
|
||||
|
||||
/**
|
||||
* @return list<string> Table names referenced by `Schema::` calls,
|
||||
* raw DDL, or DML inside the given migration
|
||||
* file contents. Empty when nothing matches —
|
||||
* callers treat that as "fall back to the
|
||||
* broad watch pattern".
|
||||
*
|
||||
* Three passes:
|
||||
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
|
||||
* captures the conventional Laravel migration shape.
|
||||
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
|
||||
* TRUNCATE / RENAME TABLE <name>` patterns inside string
|
||||
* literals (i.e. `DB::statement('CREATE TABLE …')`,
|
||||
* `DB::unprepared('ALTER TABLE …')`).
|
||||
* 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
|
||||
* `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
|
||||
* Catches the seeded-lookup-table case where a migration
|
||||
* populates rows that tests later read.
|
||||
*
|
||||
* False positives possible when the same syntax appears in a
|
||||
* comment or unrelated string, but over-attribution is
|
||||
* correctness-safe.
|
||||
*/
|
||||
public static function fromMigrationSource(string $php): array
|
||||
{
|
||||
$tables = [];
|
||||
|
||||
// Pass 1: Schema:: calls. `dropColumn` (singular) covers
|
||||
// `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
|
||||
// — the closure body's column op is on Blueprint, but the
|
||||
// outer `Schema::table('users', …)` is what we capture here.
|
||||
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
||||
|
||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $i => $primary) {
|
||||
$tables[strtolower($primary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||
|
||||
$secondary = $matches[2][$i] ?? '';
|
||||
if ($secondary !== '') {
|
||||
$tables[strtolower($secondary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: raw DDL fallback. Matches the table name following
|
||||
// `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres'
|
||||
// `IF EXISTS` / `IF NOT EXISTS` variants), with optional
|
||||
// ANSI / MySQL / SQL Server quoting.
|
||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||
|
||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $primary) {
|
||||
$lower = strtolower($primary);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$sqlPatterns = [
|
||||
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
||||
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
||||
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
||||
];
|
||||
|
||||
foreach ($sqlPatterns as $pattern) {
|
||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower(self::unqualified($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: DML inside migration bodies. Migrations that seed
|
||||
// lookup tables via `DB::statement('INSERT INTO roles …')`,
|
||||
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
|
||||
// `DELETE FROM bar` are common in Laravel. Without picking
|
||||
// these up, an edit to the seed payload would route through
|
||||
// only the schema'd tables and silently skip every test that
|
||||
// reads from the populated table. Fluent-builder calls
|
||||
// (`DB::table('x')`) and raw SQL strings are both covered.
|
||||
$dmlPatterns = [
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
||||
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
|
||||
];
|
||||
|
||||
foreach ($dmlPatterns as $pattern) {
|
||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower($name);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$lower = strtolower(self::lastDottedSegment($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
@@ -197,10 +119,38 @@ final class TableExtractor
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out driver-internal tables that show up as DB::listen
|
||||
* targets without representing user schema: SQLite's master
|
||||
* catalogue, Laravel's own `migrations` metadata.
|
||||
* The table segment of a possibly schema-qualified identifier chain,
|
||||
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
||||
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
||||
*/
|
||||
private static function unqualified(string $qualified): string
|
||||
{
|
||||
$name = '';
|
||||
|
||||
foreach (explode('.', $qualified) as $segment) {
|
||||
$segment = trim($segment, " \t\n\r\"`[]");
|
||||
|
||||
if ($segment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::isSchemaMeta($segment)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$name = $segment;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function lastDottedSegment(string $name): string
|
||||
{
|
||||
$position = strrpos($name, '.');
|
||||
|
||||
return $position === false ? $name : substr($name, $position + 1);
|
||||
}
|
||||
|
||||
private static function isSchemaMeta(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
|
||||
@@ -5,38 +5,12 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Laravel-only collaborator: during record mode, attributes every SQL
|
||||
* table the test body queries to the currently-running test.
|
||||
*
|
||||
* Why this exists: the coverage graph can tell us which PHP files a
|
||||
* test touched but cannot distinguish "this test depends on the
|
||||
* `users` table" from "this test depends on `questions`". That
|
||||
* distinction is the whole point of surgical migration invalidation —
|
||||
* a column rename in `create_questions_table.php` should only re-run
|
||||
* tests whose body actually queried `questions`.
|
||||
*
|
||||
* Mechanism: install a listener on Laravel's event dispatcher that
|
||||
* subscribes to `Illuminate\Database\Events\QueryExecuted`. Each
|
||||
* query string is piped through `TableExtractor::fromSql()`; DDL is
|
||||
* filtered at extraction time so migrations running in `setUp` don't
|
||||
* attribute every table to every test.
|
||||
*
|
||||
* Same dep-free handshake as `BladeEdges`: string class lookup +
|
||||
* method-capability probes so Pest's `require` stays Laravel-free.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class TableTracker
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent across the 774
|
||||
* per-test `setUp()` calls — Laravel reuses the same app instance
|
||||
* within a single test run, so without this guard we'd stack
|
||||
* one listener per test and each query would fire the closure
|
||||
* hundreds of times.
|
||||
*/
|
||||
private const string MARKER = 'pest.tia.table-tracker-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
@@ -85,12 +59,6 @@ final class TableTracker
|
||||
}
|
||||
};
|
||||
|
||||
// Preferred path: `DatabaseManager::listen(Closure $callback)`.
|
||||
// It's a real method — `method_exists` returns false because
|
||||
// some Laravel versions compose it via a trait the reflection
|
||||
// probe can't always see, so we gate via `is_callable` instead.
|
||||
// This path pushes the listener onto every existing AND future
|
||||
// connection, which is what we want for a process-wide capture.
|
||||
/** @var object $db */
|
||||
$db = $app->make('db');
|
||||
|
||||
@@ -102,11 +70,6 @@ final class TableTracker
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: register directly on the event dispatcher. Works
|
||||
// as long as every connection shares the same dispatcher
|
||||
// instance this app resolved to — true in vanilla setups,
|
||||
// but not guaranteed with connections instantiated pre-arm
|
||||
// that captured an older dispatcher.
|
||||
if (! $app->bound('events')) {
|
||||
return;
|
||||
}
|
||||
@@ -118,11 +81,6 @@ final class TableTracker
|
||||
return;
|
||||
}
|
||||
|
||||
// Event class key intentionally has no leading backslash —
|
||||
// `Dispatcher::listen()` stores by the literal string and the
|
||||
// lookup at dispatch time uses `get_class($event)` (no
|
||||
// leading backslash), so a `\Illuminate\…` key would never
|
||||
// match the fired event.
|
||||
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
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,
|
||||
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
|
||||
* configuration when no config file is present.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
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').
|
||||
*/
|
||||
public function __construct(
|
||||
private array $directories,
|
||||
private array $files,
|
||||
private array $suffixes,
|
||||
) {}
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
{
|
||||
$directories = [];
|
||||
$files = [];
|
||||
$suffixes = [];
|
||||
|
||||
try {
|
||||
$configuration = Registry::get();
|
||||
|
||||
foreach ($configuration->testSuite() as $suite) {
|
||||
foreach ($suite->directories() as $directory) {
|
||||
$rel = self::toRelative($directory->path(), $projectRoot);
|
||||
|
||||
if ($rel !== null) {
|
||||
$directories[] = $rel;
|
||||
}
|
||||
|
||||
$suffixes[] = $directory->suffix();
|
||||
}
|
||||
|
||||
foreach ($suite->files() as $file) {
|
||||
$rel = self::toRelative($file->path(), $projectRoot);
|
||||
|
||||
if ($rel !== null) {
|
||||
$files[] = $rel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($suffixes === []) {
|
||||
foreach ($configuration->testSuffixes() as $suffix) {
|
||||
$suffixes[] = $suffix;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Registry not initialized — fall through to defaults.
|
||||
}
|
||||
|
||||
if ($suffixes === []) {
|
||||
$suffixes = ['.php'];
|
||||
}
|
||||
|
||||
if ($directories === [] && $files === []) {
|
||||
$fallback = self::testSuiteFallback($projectRoot);
|
||||
|
||||
if ($fallback !== null) {
|
||||
$directories[] = $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
array_values(array_unique($directories)),
|
||||
array_values(array_unique($files)),
|
||||
array_values(array_unique($suffixes)),
|
||||
);
|
||||
}
|
||||
|
||||
public function isTestFile(string $relativePath): bool
|
||||
{
|
||||
if (in_array($relativePath, $this->files, true)) {
|
||||
return true;
|
||||
}
|
||||
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
|
||||
|
||||
if (! $matchesSuffix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->directories as $dir) {
|
||||
if ($dir === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($relativePath, $dir.'/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function toRelative(string $value, string $projectRoot): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$real = @realpath($value);
|
||||
$resolved = $real === false ? $value : $real;
|
||||
|
||||
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
||||
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
||||
|
||||
if (! str_starts_with($resolved.'/', $root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(substr($resolved, strlen($root)), '/');
|
||||
}
|
||||
|
||||
private static function testSuiteFallback(string $projectRoot): ?string
|
||||
{
|
||||
try {
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$real = @realpath($testPath);
|
||||
$resolved = $real === false ? $testPath : $real;
|
||||
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
||||
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
||||
|
||||
if (! str_starts_with($resolved.'/', $root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(substr($resolved, strlen($root)), '/');
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,16 @@ 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;
|
||||
|
||||
/**
|
||||
* Watch patterns for frontend assets that affect browser tests.
|
||||
*
|
||||
* Uses `BrowserTestIdentifier` from pest-plugin-browser to auto-discover tests
|
||||
* using `visit()`. Also keeps the `tests/Browser` convention when present.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Browser implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
// Browser tests can exist in any PHP project. We only activate when
|
||||
// there is an actual `tests/Browser` directory OR pest-plugin-browser
|
||||
// is installed.
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
|
||||
}
|
||||
@@ -33,18 +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',
|
||||
// Vite / Webpack build output that browser tests may consume.
|
||||
'public/build/**/*.js',
|
||||
'public/build/**/*.css',
|
||||
'resources/js/** !*.php',
|
||||
'resources/css/** !*.php',
|
||||
'public/hot !*.php',
|
||||
'public/** !*.php',
|
||||
];
|
||||
|
||||
$patterns = [];
|
||||
@@ -69,9 +54,6 @@ final readonly class Browser implements WatchDefault
|
||||
$targets[] = $candidate;
|
||||
}
|
||||
|
||||
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
||||
// is installed to find exact tests using `visit()` outside the
|
||||
// conventional Browser/ folder.
|
||||
if (class_exists(BrowserTestIdentifier::class)) {
|
||||
$repo = TestSuite::getInstance()->tests;
|
||||
|
||||
|
||||
@@ -5,14 +5,9 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* Watch patterns for Inertia.js projects (Laravel or otherwise).
|
||||
*
|
||||
* Inertia bridges PHP controllers with JS/TS page components. A change to
|
||||
* a React / Vue / Svelte page can break assertions in browser tests or
|
||||
* Inertia-specific feature tests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Inertia implements WatchDefault
|
||||
@@ -26,41 +21,8 @@ final readonly class Inertia implements WatchDefault
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
|
||||
|
||||
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||
// browser tests only — a Vue/React edit cannot change the
|
||||
// output of a server-side Inertia test (those assert on the
|
||||
// component *name* returned by `Inertia::render()`, not its
|
||||
// client-side implementation). Broad invalidation is only
|
||||
// meaningful for tests that actually render the DOM. Precise
|
||||
// per-component edges come from `InertiaEdges` at record
|
||||
// time and replace this fallback when available.
|
||||
//
|
||||
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
|
||||
// React starter kit, and other lowercase-by-default setups)
|
||||
// are emitted — paths from git are case-sensitive on Linux,
|
||||
// so a single casing would silently miss the other convention.
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
// SSR entry point.
|
||||
$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,16 +5,9 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* Watch patterns for Laravel projects.
|
||||
*
|
||||
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
|
||||
* `Prepared` event where TIA's coverage window opens). That means PHP files
|
||||
* loaded during boot — config, routes, service providers, migrations — are
|
||||
* invisible to the coverage driver. Watch patterns are the only way to
|
||||
* track them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Laravel implements WatchDefault
|
||||
@@ -28,54 +21,21 @@ final readonly class Laravel implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
// Config — loaded during app boot (setUp), invisible to coverage.
|
||||
// Affects both Feature and Unit: Pest.php commonly binds fakes
|
||||
// and seeds DB based on config values.
|
||||
'config/*.php' => [$testPath],
|
||||
'config/**/*.php' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
||||
'routes/*.php' => [$testPath],
|
||||
'routes/**/*.php' => [$testPath],
|
||||
|
||||
// Service providers / bootstrap — loaded during boot, affect
|
||||
// bindings, middleware, event listeners, scheduled tasks.
|
||||
'bootstrap/app.php' => [$testPath],
|
||||
'bootstrap/providers.php' => [$testPath],
|
||||
|
||||
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
|
||||
// setUp. Schema changes can break any test that touches DB.
|
||||
'database/migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Seeders — often run globally via Pest.php beforeEach.
|
||||
'database/seeders/**/*.php' => [$testPath],
|
||||
'storage/fixtures/**/*' => [$testPath],
|
||||
|
||||
// Factories — loaded lazily but still PHP that coverage may miss
|
||||
// if the factory file was already autoloaded before Prepared.
|
||||
'database/factories/**/*.php' => [$testPath],
|
||||
'app/** !*.php' => [$testPath],
|
||||
|
||||
// Blade templates — compiled to cache, source file not executed.
|
||||
'resources/views/**/*.blade.php' => [$testPath],
|
||||
// Email templates are nested under views/email or views/emails
|
||||
// by convention and power mailable tests that render markup.
|
||||
'resources/views/email/**/*.blade.php' => [$testPath],
|
||||
'resources/views/emails/**/*.blade.php' => [$testPath],
|
||||
'resources/views/**' => [$testPath],
|
||||
|
||||
// Translations — JSON translations read via file_get_contents,
|
||||
// PHP translations loaded via include (but during boot).
|
||||
'lang/**/*.php' => [$testPath],
|
||||
'lang/**/*.json' => [$testPath],
|
||||
'resources/lang/**/*.php' => [$testPath],
|
||||
'resources/lang/**/*.json' => [$testPath],
|
||||
'lang/**' => [$testPath],
|
||||
'resources/lang/**' => [$testPath],
|
||||
|
||||
// Build tool config — affects compiled assets consumed by
|
||||
// browser and Inertia tests.
|
||||
'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,14 +5,9 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* Watch patterns for projects using Livewire.
|
||||
*
|
||||
* Livewire components pair a PHP class with a Blade view. A view change can
|
||||
* break rendering or assertions in feature / browser tests even though the
|
||||
* PHP side is untouched.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Livewire implements WatchDefault
|
||||
@@ -26,15 +21,10 @@ final readonly class Livewire implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
// Livewire views live alongside Blade views or in a dedicated dir.
|
||||
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
||||
'resources/views/components/**/*.blade.php' => [$testPath],
|
||||
// Volt's second default mount — single-file components used as
|
||||
// full-page routes. Missing this means editing a Volt page
|
||||
// doesn't re-run its tests.
|
||||
'resources/views/pages/**/*.blade.php' => [$testPath],
|
||||
|
||||
// Livewire JS interop / Alpine plugins.
|
||||
'resources/js/**/*.js' => [$testPath],
|
||||
'resources/js/**/*.ts' => [$testPath],
|
||||
];
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* Baseline watch patterns for any PHP project.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Php implements WatchDefault
|
||||
@@ -18,55 +18,20 @@ final readonly class Php implements WatchDefault
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
// NOTE: composer.json / composer.lock changes are caught by the
|
||||
// fingerprint (which hashes composer.lock). PHP files are tracked by
|
||||
// the coverage driver. Only non-PHP, non-fingerprinted files that
|
||||
// can silently alter test behaviour belong here.
|
||||
|
||||
return [
|
||||
// Environment files — can change DB drivers, feature flags,
|
||||
// queue connections, etc. Not PHP, not fingerprinted. Covers
|
||||
// the local-override variants (`.env.local`, `.env.testing.local`)
|
||||
// that both Laravel and Symfony recommend for machine-specific
|
||||
// config.
|
||||
'.env' => [$testPath],
|
||||
'.env.testing' => [$testPath],
|
||||
'.env.local' => [$testPath],
|
||||
'.env.*.local' => [$testPath],
|
||||
|
||||
// Docker / CI — can affect integration test infrastructure.
|
||||
'docker-compose.yml' => [$testPath],
|
||||
'docker-compose.yaml' => [$testPath],
|
||||
|
||||
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
|
||||
// phpunit.xml.dist and other XML overrides are not individually
|
||||
// tracked by the coverage driver.
|
||||
'phpunit.xml.dist' => [$testPath],
|
||||
'phpunit.xml*' => [$testPath],
|
||||
|
||||
// `tests/Pest.php` is loaded once per suite (during BootFiles)
|
||||
// so its `pest()->extend()`, `expect()->extend()`, helpers,
|
||||
// etc. execute outside the per-test coverage window — no
|
||||
// edge captures it. Watch-pattern broadcast triggers a
|
||||
// replay of every test (results refresh) without a full
|
||||
// record-mode graph rebuild.
|
||||
$testPath.'/Pest.php' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*' => [$testPath],
|
||||
$testPath.'/**/Fixtures/**/*' => [$testPath],
|
||||
|
||||
// Pest dataset definitions are loaded once at boot, outside
|
||||
// the per-test coverage window — no edge captures them. A
|
||||
// change to a shared dataset can flip the result of any test
|
||||
// that uses it, so broadcast every dataset edit to the full
|
||||
// suite.
|
||||
$testPath.'/Datasets/**/*.php' => [$testPath],
|
||||
|
||||
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
||||
// assertions. A fixture change can flip a test result.
|
||||
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.csv' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.xml' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.txt' => [$testPath],
|
||||
|
||||
// Pest snapshots — external edits to snapshot files invalidate
|
||||
// snapshot assertions.
|
||||
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
|
||||
/**
|
||||
* Watch patterns for Symfony projects.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Symfony implements WatchDefault
|
||||
@@ -21,59 +20,23 @@ final readonly class Symfony implements WatchDefault
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
// Symfony boots the kernel in setUp() (before the coverage window).
|
||||
// PHP config, routes, kernel, and migrations are loaded during boot
|
||||
// and invisible to the coverage driver. Same reasoning as Laravel.
|
||||
|
||||
return [
|
||||
// Config — YAML, XML, and PHP. All loaded during kernel boot.
|
||||
'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/** !*.php' => [$testPath],
|
||||
'config/routes/** !*.php' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot.
|
||||
'config/routes/*.yaml' => [$testPath],
|
||||
'config/routes/*.php' => [$testPath],
|
||||
'config/routes/*.xml' => [$testPath],
|
||||
'config/routes/**/*.yaml' => [$testPath],
|
||||
|
||||
// Kernel / bootstrap — loaded during boot.
|
||||
'src/Kernel.php' => [$testPath],
|
||||
|
||||
// Migrations — run during setUp (before coverage window).
|
||||
// DoctrineMigrationsBundle's default is `migrations/` at the
|
||||
// project root; many Symfony projects relocate to
|
||||
// `src/Migrations/` — both covered.
|
||||
'migrations/**/*.php' => [$testPath],
|
||||
'src/Migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Twig templates — compiled, source not PHP-executed.
|
||||
'templates/**/*.html.twig' => [$testPath],
|
||||
'templates/**/*.twig' => [$testPath],
|
||||
'templates/** !*.php' => [$testPath],
|
||||
|
||||
// Translations (YAML / XLF / XLIFF).
|
||||
'translations/**/*.yaml' => [$testPath],
|
||||
'translations/**/*.yml' => [$testPath],
|
||||
'translations/**/*.xlf' => [$testPath],
|
||||
'translations/**/*.xliff' => [$testPath],
|
||||
'translations/** !*.php' => [$testPath],
|
||||
|
||||
// Doctrine XML/YAML mappings.
|
||||
'config/doctrine/**/*.xml' => [$testPath],
|
||||
'config/doctrine/**/*.yaml' => [$testPath],
|
||||
|
||||
// Webpack Encore / asset-mapper config + frontend sources.
|
||||
'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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
/**
|
||||
* A set of file-watch patterns that apply when a particular framework,
|
||||
* library or project layout is detected.
|
||||
*
|
||||
* Each implementation probes for the presence of the tool it covers
|
||||
* (`applicable`) and returns glob → test-directory mappings (`defaults`)
|
||||
* that are merged into `WatchPatterns`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface WatchDefault
|
||||
{
|
||||
/**
|
||||
* Whether this default set applies to the current project.
|
||||
*/
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> glob → list of project-relative test dirs
|
||||
*/
|
||||
public function defaults(string $projectRoot, string $testPath): array;
|
||||
}
|
||||
@@ -4,29 +4,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* Maps non-PHP file globs to the tests they should invalidate.
|
||||
*
|
||||
* Coverage drivers only see `.php` files. Frontend assets, config files,
|
||||
* Blade templates, routes and environment files are invisible to the graph.
|
||||
* Watch patterns bridge the gap: when a changed file matches a glob, every
|
||||
* test under the associated directory (or the exact associated test file) is
|
||||
* marked as affected.
|
||||
*
|
||||
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
|
||||
* each implementation probes the current project and contributes patterns
|
||||
* when applicable. Users extend via `pest()->tia()->watch(…)`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WatchPatterns
|
||||
{
|
||||
/**
|
||||
* All known default providers, in evaluation order.
|
||||
*
|
||||
* @var array<int, class-string<WatchDefault>>
|
||||
*/
|
||||
private const array DEFAULTS = [
|
||||
@@ -38,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 = [];
|
||||
|
||||
/**
|
||||
* Probes every registered `WatchDefault` and merges the patterns of
|
||||
* those that apply. Called once during Tia plugin boot, after BootFiles
|
||||
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
|
||||
* calls are already in `$this->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;
|
||||
@@ -60,33 +55,27 @@ 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),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds user-defined patterns. Merges with existing entries so a single
|
||||
* glob can map to multiple directories.
|
||||
*
|
||||
* @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]),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all test targets whose watch patterns match at least one of
|
||||
* the given changed files.
|
||||
*
|
||||
* @param string $projectRoot Absolute path.
|
||||
* @param array<int, string> $changedFiles Project-relative paths.
|
||||
* @return array<int, string> Project-relative test dirs/files.
|
||||
@@ -100,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,9 +104,6 @@ final class WatchPatterns
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the affected targets, returns every test file in the graph that
|
||||
* either matches an exact file target or lives under a directory target.
|
||||
*
|
||||
* @param array<int, string> $directories Project-relative dirs/files.
|
||||
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
||||
* @return array<int, string>
|
||||
@@ -149,16 +137,146 @@ final class WatchPatterns
|
||||
return $affected;
|
||||
}
|
||||
|
||||
public function markEnabled(): void
|
||||
{
|
||||
$this->enabled = true;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function markLocally(): void
|
||||
{
|
||||
$this->locally = true;
|
||||
}
|
||||
|
||||
public function isLocally(): bool
|
||||
{
|
||||
return $this->locally;
|
||||
}
|
||||
|
||||
public function markFiltered(): void
|
||||
{
|
||||
$this->filtered = true;
|
||||
}
|
||||
|
||||
public function isFiltered(): bool
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a project-relative file against a glob pattern.
|
||||
*
|
||||
* Supports `*` (single segment), `**` (any depth) and `?`.
|
||||
* @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
|
||||
{
|
||||
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||
}
|
||||
|
||||
private function touchesVcs(string $file): bool
|
||||
{
|
||||
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
|
||||
}
|
||||
|
||||
private function touchesDotfile(string $file): bool
|
||||
{
|
||||
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$pattern = str_replace('\\', '/', $pattern);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Restarters;
|
||||
|
||||
use Pest\Contracts\Restarter;
|
||||
use Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class PcovRestarter implements Restarter
|
||||
{
|
||||
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function maybeRestart(string $projectRoot, array $arguments): void
|
||||
{
|
||||
if (! extension_loaded('pcov')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getenv(self::ENV_RESTARTED) === '1') {
|
||||
putenv(self::ENV_RESTARTED);
|
||||
unset($_ENV[self::ENV_RESTARTED]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Tia::isEnabledForRun($arguments)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$desired = $this->normalise($projectRoot);
|
||||
$current = $this->normalise((string) ini_get('pcov.directory'));
|
||||
|
||||
if ($current === $desired) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->restart($projectRoot, $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function restart(string $projectRoot, array $arguments): void
|
||||
{
|
||||
$env = $this->inheritEnv();
|
||||
$env[self::ENV_RESTARTED] = '1';
|
||||
|
||||
$command = array_merge(
|
||||
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
|
||||
array_values($arguments),
|
||||
);
|
||||
|
||||
$proc = @proc_open(
|
||||
$command,
|
||||
[STDIN, STDOUT, STDERR],
|
||||
$pipes,
|
||||
null,
|
||||
$env,
|
||||
);
|
||||
|
||||
if (! is_resource($proc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exitCode = proc_close($proc);
|
||||
|
||||
exit($exitCode === -1 ? 1 : $exitCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function inheritEnv(): array
|
||||
{
|
||||
$env = [];
|
||||
|
||||
foreach (getenv() as $name => $value) {
|
||||
$env[$name] = $value;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
private function normalise(string $path): string
|
||||
{
|
||||
return rtrim($path, '/\\');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Restarters;
|
||||
|
||||
use Composer\XdebugHandler\XdebugHandler;
|
||||
use Pest\Contracts\Restarter;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class XdebugRestarter implements Restarter
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function maybeRestart(string $projectRoot, array $arguments): void
|
||||
{
|
||||
if (! class_exists(XdebugHandler::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! extension_loaded('xdebug')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->xdebugIsCoverageOnly()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->runLooksDroppable($arguments, $projectRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new XdebugHandler('pest')->check();
|
||||
}
|
||||
|
||||
private function xdebugIsCoverageOnly(): bool
|
||||
{
|
||||
if (! function_exists('xdebug_info')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$modes = @xdebug_info('mode');
|
||||
|
||||
if (! is_array($modes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$modes = array_values(array_filter($modes, is_string(...)));
|
||||
|
||||
if ($modes === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $modes === ['coverage'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function runLooksDroppable(array $arguments, string $projectRoot): bool
|
||||
{
|
||||
foreach ($arguments as $value) {
|
||||
if ($value === '--coverage'
|
||||
|| str_starts_with($value, '--coverage=')
|
||||
|| str_starts_with($value, '--coverage-')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($value === '--fresh') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (! Tia::isEnabledForRun($arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tiaWillReplay($projectRoot);
|
||||
}
|
||||
|
||||
private function tiaWillReplay(string $projectRoot): bool
|
||||
{
|
||||
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
||||
|
||||
if (! is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = @file_get_contents($path);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$graph = Graph::decode($json, $projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Fingerprint::structuralMatches(
|
||||
$graph->fingerprint(),
|
||||
Fingerprint::compute($projectRoot),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,20 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
final class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
{
|
||||
/**
|
||||
* Indicates if the TeamCity logger has already been registered.
|
||||
*/
|
||||
private static bool $registered = false;
|
||||
|
||||
/**
|
||||
* Creates a new Configured Subscriber instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private InputInterface $input,
|
||||
private OutputInterface $output,
|
||||
private TestSuite $testSuite,
|
||||
private readonly InputInterface $input,
|
||||
private readonly OutputInterface $output,
|
||||
private readonly TestSuite $testSuite,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,10 +36,16 @@ final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
*/
|
||||
public function notify(Configured $event): void
|
||||
{
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->input->hasParameterOption('--teamcity')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$registered = true;
|
||||
|
||||
$flowId = getenv('FLOW_ID');
|
||||
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
|
||||
/**
|
||||
* Fires last for each test, after the outcome subscribers. Records the exact
|
||||
* assertion count so replay can emit the same `addToAssertionCount()` instead
|
||||
* of a hardcoded value.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
|
||||
@@ -31,10 +27,6 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
|
||||
);
|
||||
}
|
||||
|
||||
// Close the "currently recording" window on Finished so the next
|
||||
// test's events don't get mis-attributed. Keeping the pointer open
|
||||
// through the outcome subscribers is what lets a late-firing
|
||||
// `ConsideredRisky` overwrite an earlier `Passed`.
|
||||
$this->collector->finishTest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,9 @@ use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
|
||||
/**
|
||||
* Stops PCOV collection after each test and merges the covered files into the
|
||||
* TIA recorder's aggregate map. No-op unless the recorder is active.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
|
||||
final readonly class EnsureTiaEnds implements FinishedSubscriber
|
||||
{
|
||||
public function __construct(private Recorder $recorder) {}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscriber
|
||||
{
|
||||
public function __construct(private Recorder $recorder) {}
|
||||
|
||||
public function notify(Prepared $event): void
|
||||
{
|
||||
if (! $this->recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$test = $event->test();
|
||||
|
||||
if (! $test instanceof TestMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
$className = $test->className();
|
||||
|
||||
if (! class_exists($className, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (method_exists($className, '__initializeTestCase')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panic::with(new TiaRequiresPestTests($className, $test->file()));
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,22 @@ 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;
|
||||
|
||||
/**
|
||||
* Starts a per-test recording window on Prepared. Sibling subscribers
|
||||
* (`EnsureTia*`) close it with the outcome and the assertion count so the
|
||||
* graph can persist everything needed for faithful replay.
|
||||
*
|
||||
* Why one subscriber per event: PHPUnit's `TypeMap::map()` picks only the
|
||||
* first subscriber interface it finds on a class, so one class cannot fan
|
||||
* out to multiple events — each event needs its own subscriber class.
|
||||
*
|
||||
* @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();
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName());
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -10,13 +10,9 @@ use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
|
||||
/**
|
||||
* Starts PCOV collection before each test. Pest tests start from
|
||||
* `Testable::setUp()` so Laravel boot is covered; this subscriber remains the
|
||||
* fallback for PHPUnit-style tests and is idempotent for Pest tests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
|
||||
final readonly class EnsureTiaStarts implements PreparedSubscriber
|
||||
{
|
||||
public function __construct(private Recorder $recorder) {}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||
use SebastianBergmann\CodeCoverage\Node\File;
|
||||
use SebastianBergmann\CodeCoverage\Report\Facade;
|
||||
use SebastianBergmann\Environment\Runtime;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -89,20 +90,24 @@ 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 */
|
||||
$codeCoverage = require $reportPath;
|
||||
unlink($reportPath);
|
||||
|
||||
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
|
||||
// @phpstan-ignore-next-line
|
||||
if (is_array($codeCoverage)) {
|
||||
$facade = Facade::fromSerializedData($codeCoverage);
|
||||
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = $codeCoverage->getReport();
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = (fn (): Directory => $this->report)->call($facade);
|
||||
} else {
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = $codeCoverage->getReport();
|
||||
}
|
||||
|
||||
$totalCoverage = $report->percentageOfExecutedLines();
|
||||
|
||||
foreach ($report->getIterator() as $file) {
|
||||
if (! $file instanceof File) {
|
||||
|
||||
@@ -86,4 +86,17 @@ final readonly class Exporter
|
||||
|
||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a value into a full single-line string without truncation.
|
||||
*/
|
||||
public function export(mixed $value): string
|
||||
{
|
||||
$map = [
|
||||
'#\\\n\s*#' => '',
|
||||
'# Object \(\.{3}\)#' => '',
|
||||
];
|
||||
|
||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ final class HigherOrderMessage
|
||||
}
|
||||
|
||||
if ($this->hasHigherOrderCallable()) {
|
||||
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
|
||||
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user