mirror of
https://github.com/pestphp/pest.git
synced 2026-07-28 20:38:57 +02:00
Compare commits
217 Commits
07416a3c61
..
v5.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b2cd358e8 | |||
| e09e1be76d | |||
| 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 | |||
| 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/pest5-now-available)**.
|
||||
|
||||
**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();
|
||||
|
||||
+302
-109
@@ -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,83 +235,122 @@ 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,
|
||||
const loaded = await loadRolldown()
|
||||
|
||||
if (loaded === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
},
|
||||
appType: 'custom',
|
||||
optimizeDeps: { disabled: true },
|
||||
}
|
||||
|
||||
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 input = Object.create(null)
|
||||
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
||||
|
||||
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: () => {},
|
||||
})
|
||||
|
||||
// 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))
|
||||
try {
|
||||
await bundle.generate({ format: 'esm' })
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
|
||||
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 reverse = new Map()
|
||||
const transitiveCache = new Map()
|
||||
|
||||
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.
|
||||
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
|
||||
}
|
||||
}
|
||||
stack.delete(id)
|
||||
if (complete) transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
|
||||
if (!pageModule) continue
|
||||
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
|
||||
const rel = relative(PROJECT_ROOT, id).split(sep).join('/')
|
||||
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)
|
||||
|
||||
queue.push(imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(killer)
|
||||
await server.close()
|
||||
}
|
||||
|
||||
const payload = Object.create(null)
|
||||
const keys = [...reverse.keys()].sort()
|
||||
@@ -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
|
||||
const invokedDirectly = process.argv[1] !== undefined
|
||||
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
await main()
|
||||
} catch (err) {
|
||||
} 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,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# Pest Testing 5
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||
|
||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||
|
||||
@boostsnippet("Basic Pest Test Example", "php")
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
@boostsnippet("Pest Response Assertion", "php")
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
@boostsnippet("Pest Dataset Example", "php")
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
@endboostsnippet
|
||||
|
||||
## Pest 5 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Tia (Test Impact Analysis)
|
||||
|
||||
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||
|
||||
@boostsnippet("Tia Example", "shell")
|
||||
./vendor/bin/pest --parallel --tia
|
||||
@endboostsnippet
|
||||
|
||||
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||
|
||||
### New Validation Expectations
|
||||
|
||||
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||
|
||||
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||
expect('nuno@pestphp.com')->toBeEmail();
|
||||
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||
expect('192.168.1.1')->toBeIpAddress();
|
||||
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||
expect('example.com')->toBeHostname();
|
||||
expect('example.co.uk')->toBeDomain();
|
||||
expect('Zm9vYmFy')->toBeBase64();
|
||||
expect('deadbeef')->toBeHexadecimal();
|
||||
@endboostsnippet
|
||||
|
||||
### Time-Balanced Sharding
|
||||
|
||||
Distribute tests across CI shards by execution time rather than count:
|
||||
|
||||
@boostsnippet("Pest Sharding Example", "shell")
|
||||
./vendor/bin/pest --update-shards
|
||||
./vendor/bin/pest --shard=1/4
|
||||
@endboostsnippet
|
||||
|
||||
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
@boostsnippet("Pest Browser Test Example", "php")
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
@endboostsnippet
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
@boostsnippet("Architecture Test Example", "php")
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
@endboostsnippet
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||
@@ -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
-91
@@ -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,75 +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;
|
||||
$this->__ran = true;
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
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'),
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
$this->__ran = 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());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
||||
}
|
||||
|
||||
$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];
|
||||
|
||||
@@ -352,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,7 +355,7 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
if ($this->__replay !== ReplayType::None) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
@@ -429,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;
|
||||
}
|
||||
@@ -483,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();
|
||||
@@ -502,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');
|
||||
|
||||
@@ -510,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);
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Renders the panic on the given output.
|
||||
*/
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
@@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit code to be used.
|
||||
*/
|
||||
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()
|
||||
{
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
+22
-19
@@ -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 = [];
|
||||
|
||||
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();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return $this->getTestCaseLocation($event->test());
|
||||
},
|
||||
$events
|
||||
)
|
||||
)
|
||||
);
|
||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||
}
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||
$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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+563
-299
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;
|
||||
}
|
||||
}
|
||||
+293
-192
@@ -4,20 +4,15 @@ 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;
|
||||
|
||||
/**
|
||||
* Downloads a team-shared TIA baseline from GitHub workflow artifacts so new contributors and
|
||||
* fresh CI workspaces start in replay mode. Artifacts are used instead of releases because they
|
||||
* produce no tag (no push cascade), support tunable retention, and can only be published by CI.
|
||||
*
|
||||
* Fingerprint validation happens in `Tia::handleParent` after the blobs land; a mismatched
|
||||
* environment falls through to the normal record path.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class BaselineSync
|
||||
@@ -30,28 +25,51 @@ final readonly class BaselineSync
|
||||
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
// Subdirectory under the per-project state dir (`~/.pest/tia/<project>/`)
|
||||
// where artifacts from previous downloads are kept (one subfolder per
|
||||
// workflow run id). Hitting the same run id on a later fetch skips
|
||||
// the `gh run download` round trip entirely — artifacts are immutable
|
||||
// per run id, so the cached bytes are exactly what gh would re-download.
|
||||
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
|
||||
|
||||
// Most recently downloaded artifacts to retain on disk. Branch
|
||||
// switches and partial baseline rollouts hop across run ids — keeping
|
||||
// the last few avoids re-downloading when the user toggles between
|
||||
// them. Older entries get evicted on the next download.
|
||||
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
|
||||
|
||||
// 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`.
|
||||
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,
|
||||
) {}
|
||||
|
||||
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);
|
||||
|
||||
@@ -60,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, $projectRoot);
|
||||
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
||||
$payload = $result['payload'];
|
||||
$failureKind = $result['failureKind'];
|
||||
|
||||
if ($payload === null) {
|
||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions($repo);
|
||||
$this->emitPublishInstructions();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -93,11 +109,6 @@ final readonly class BaselineSync
|
||||
|
||||
$this->clearCooldown();
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||
));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -145,45 +156,18 @@ final readonly class BaselineSync
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
|
||||
private function isCi(): bool
|
||||
{
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
@@ -191,79 +175,6 @@ final readonly class BaselineSync
|
||||
|| getenv('CIRCLECI') === 'true';
|
||||
}
|
||||
|
||||
private function isLaravel(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
|
||||
private function laravelWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
name: TIA Baseline
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
schedule: [{ cron: '0 3 * * *' }]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
coverage: xdebug
|
||||
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
|
||||
- run: cp .env.example .env
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: php artisan key:generate
|
||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||
- name: Stage baseline for upload
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p .pest-tia-baseline
|
||||
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: .pest-tia-baseline/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
private function genericWorkflowYaml(): string
|
||||
{
|
||||
return <<<'YAML'
|
||||
name: TIA Baseline
|
||||
on:
|
||||
push: { branches: [main] }
|
||||
schedule: [{ cron: '0 3 * * *' }]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
baseline:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with: { php-version: '8.4', coverage: xdebug }
|
||||
- run: composer install --no-interaction --prefer-dist
|
||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
||||
- name: Stage baseline for upload
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p .pest-tia-baseline
|
||||
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pest-tia-baseline
|
||||
path: .pest-tia-baseline/
|
||||
retention-days: 30
|
||||
YAML;
|
||||
}
|
||||
|
||||
private function detectGitHubRepo(string $projectRoot): ?string
|
||||
{
|
||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
@@ -299,36 +210,115 @@ YAML;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array{graph: string, coverage: ?string}|null */
|
||||
private function download(string $repo, string $projectRoot): ?array
|
||||
/**
|
||||
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
|
||||
*/
|
||||
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
return null;
|
||||
$this->validateGhDependencies($hasAnchor);
|
||||
|
||||
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
||||
|
||||
if ($listError !== null) {
|
||||
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
));
|
||||
|
||||
return ['payload' => null, 'failureKind' => $listError['kind']];
|
||||
}
|
||||
|
||||
$runId = $this->latestSuccessfulRunId($repo);
|
||||
|
||||
if ($runId === null) {
|
||||
return null;
|
||||
return ['payload' => null, 'failureKind' => 'no-runs'];
|
||||
}
|
||||
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
// Cache hit: a previous fetch already extracted this run id's
|
||||
// artifact into the run-specific dir. Read the assets straight
|
||||
// out of it and skip `gh run download` entirely.
|
||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
||||
// Bump the dir mtime so trimDownloadCache() treats this run
|
||||
// id as recently used and doesn't evict it later.
|
||||
@touch($runCacheDir);
|
||||
|
||||
return $this->readArtifact($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 null;
|
||||
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,
|
||||
@@ -336,29 +326,122 @@ YAML;
|
||||
'-D', $runCacheDir,
|
||||
]);
|
||||
$process->setTimeout(900.0);
|
||||
$process->run();
|
||||
$process->start();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
$this->cleanup($runCacheDir);
|
||||
$startedAt = microtime(true);
|
||||
$tick = 0;
|
||||
|
||||
return null;
|
||||
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);
|
||||
|
||||
return null;
|
||||
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,
|
||||
));
|
||||
}
|
||||
|
||||
$this->trimDownloadCache($projectRoot);
|
||||
|
||||
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()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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}|null
|
||||
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
|
||||
*/
|
||||
private function readArtifact(string $dir): ?array
|
||||
{
|
||||
@@ -376,6 +459,7 @@ YAML;
|
||||
return [
|
||||
'graph' => $graph,
|
||||
'coverage' => $coverage === false ? null : $coverage,
|
||||
'sizeOnDisk' => $this->dirSize($dir),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -384,11 +468,6 @@ YAML;
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run ids returned by `gh` are numeric strings, but defend against a
|
||||
* surprising response by stripping anything non-alphanumeric — the
|
||||
* value is used as a directory name.
|
||||
*/
|
||||
private function safeRunId(string $runId): string
|
||||
{
|
||||
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
||||
@@ -396,13 +475,6 @@ YAML;
|
||||
return $sanitised === '' ? 'unknown' : $sanitised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the N most recently used cached artifacts and evict the rest.
|
||||
* Recency is taken from the directory mtime — `mkdir`/`gh run download`
|
||||
* stamps it on a fresh entry, and a cache hit `touch`es it back to
|
||||
* the front of the line, so a frequently-reused run id won't be
|
||||
* evicted just because newer ids have been seen between uses.
|
||||
*/
|
||||
private function trimDownloadCache(string $projectRoot): void
|
||||
{
|
||||
$root = $this->downloadCacheDir($projectRoot);
|
||||
@@ -420,7 +492,7 @@ YAML;
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if (in_array($entry, ['.', '..'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -448,7 +520,10 @@ YAML;
|
||||
}
|
||||
}
|
||||
|
||||
private function latestSuccessfulRunId(string $repo): ?string
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
||||
*/
|
||||
private function latestSuccessfulRunIdWithError(string $repo): array
|
||||
{
|
||||
$process = new Process([
|
||||
'gh', 'run', 'list',
|
||||
@@ -463,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
|
||||
@@ -492,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
|
||||
@@ -31,13 +19,12 @@ final readonly class ChangedFiles
|
||||
* @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 with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
|
||||
$candidates = array_fill_keys($files, true);
|
||||
|
||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
||||
@@ -48,46 +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) {
|
||||
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $exists) {
|
||||
// Always invalidate deletions — a stale cached result from before the deletion
|
||||
// would persist forever otherwise, even if the snapshot recorded the empty sentinel.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
if ($hash === false) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($hash === $snapshot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$remaining[] = $file;
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
private function currentHash(string $relativePath): ?string
|
||||
{
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -99,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;
|
||||
@@ -119,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 !== '') {
|
||||
@@ -140,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);
|
||||
}
|
||||
@@ -180,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;
|
||||
@@ -200,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;
|
||||
}
|
||||
}
|
||||
@@ -217,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);
|
||||
@@ -236,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());
|
||||
@@ -278,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(
|
||||
@@ -294,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;
|
||||
}
|
||||
|
||||
@@ -312,7 +242,7 @@ final readonly class ChangedFiles
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
return $this->splitLines($process->getOutput());
|
||||
@@ -323,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,
|
||||
@@ -338,7 +260,7 @@ final readonly class ChangedFiles
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
@@ -361,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;
|
||||
|
||||
@@ -382,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,26 +7,11 @@ 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
|
||||
{
|
||||
/**
|
||||
* Activates TIA for every run without requiring the `--tia` CLI flag.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function always(): self
|
||||
@@ -39,10 +24,6 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the `always()` activation to local environments only.
|
||||
* On CI (`--ci` flag or `CI` env var), TIA is skipped even if `always()` is set.
|
||||
* Explicit `--tia` on the CLI always takes effect regardless.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function locally(): self
|
||||
@@ -56,10 +37,6 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* In replay mode, instead of short-circuiting cached results for unaffected
|
||||
* tests, narrows PHPUnit to only the affected files — unaffected tests are
|
||||
* never loaded. Can also be enabled with the `--filtered` CLI flag.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function filtered(): self
|
||||
@@ -72,9 +49,18 @@ final class Configuration
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds watch-pattern → test-directory mappings that supplement (or
|
||||
* override) the built-in defaults.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
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
|
||||
{
|
||||
$resolved = @realpath($this->rootDir);
|
||||
|
||||
return $resolved === false ? null : $resolved;
|
||||
if ($this->resolvedRoot !== null) {
|
||||
return $this->resolvedRoot;
|
||||
}
|
||||
|
||||
$resolved = @realpath($this->rootDir);
|
||||
|
||||
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)) {
|
||||
|
||||
+105
-367
@@ -4,79 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Two-bucket fingerprint for TIA staleness detection.
|
||||
*
|
||||
* - **structural**: inputs whose drift means graph *edges* may be wrong → full rebuild.
|
||||
* `tests/TestCase.php` and `tests/Pest.php` are intentionally absent; they're covered by
|
||||
* `Recorder::linkAncestorFiles` and the watch pattern, giving precise per-test invalidation.
|
||||
* - **environmental**: runtime inputs (PHP version, extensions, env files) whose drift means
|
||||
* edges are still valid but cached results may not reproduce → drop results and re-run.
|
||||
* Pest's own version is absent; `composer.lock` moves whenever Pest is upgraded.
|
||||
*
|
||||
* @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.
|
||||
// v14: Node/Vite resolver inputs (`package*.json`, `tsconfig.*`,
|
||||
// `jsconfig.*`) are included in the structural bucket. They can
|
||||
// reshape the persisted JS module graph without touching
|
||||
// `vite.config.*` itself.
|
||||
private const int SCHEMA_VERSION = 14;
|
||||
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
|
||||
@@ -85,21 +33,21 @@ final readonly class Fingerprint
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
|
||||
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
'vite_config' => self::viteConfigHash($projectRoot),
|
||||
'package_json' => self::packageJsonHash($projectRoot),
|
||||
// 'package_json' => self::packageJsonHash($projectRoot),
|
||||
'package_lock' => self::packageLockHash($projectRoot),
|
||||
'js_config' => self::jsConfigHash($projectRoot),
|
||||
'composer_json' => self::composerJsonHash($projectRoot),
|
||||
// 'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'environmental' => [
|
||||
// Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs.
|
||||
'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),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -126,30 +74,11 @@ final readonly class Fingerprint
|
||||
*/
|
||||
public static function structuralDrift(array $stored, array $current): array
|
||||
{
|
||||
$a = self::structuralOnly($stored);
|
||||
$b = self::structuralOnly($current);
|
||||
|
||||
$drifts = [];
|
||||
|
||||
foreach ($a as $key => $value) {
|
||||
if ($key === 'schema') {
|
||||
continue;
|
||||
}
|
||||
if (($b[$key] ?? null) !== $value) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($b as $key => $value) {
|
||||
if ($key === 'schema') {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($key, $a) && $value !== null) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($drifts));
|
||||
return self::detectDrift(
|
||||
self::structuralOnly($stored),
|
||||
self::structuralOnly($current),
|
||||
'schema',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,18 +88,34 @@ final readonly class Fingerprint
|
||||
*/
|
||||
public static function environmentalDrift(array $stored, array $current): array
|
||||
{
|
||||
$a = self::environmentalOnly($stored);
|
||||
$b = self::environmentalOnly($current);
|
||||
return self::detectDrift(
|
||||
self::environmentalOnly($stored),
|
||||
self::environmentalOnly($current),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
|
||||
{
|
||||
$drifts = [];
|
||||
|
||||
foreach ($a as $key => $value) {
|
||||
if ($key === $skipKey) {
|
||||
continue;
|
||||
}
|
||||
if (($b[$key] ?? null) !== $value) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($b as $key => $value) {
|
||||
if ($key === $skipKey) {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($key, $a) && $value !== null) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
@@ -197,7 +142,6 @@ final readonly class Fingerprint
|
||||
return self::bucket($fingerprint, 'environmental');
|
||||
}
|
||||
|
||||
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
@@ -225,7 +169,11 @@ final readonly class Fingerprint
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
@@ -241,6 +189,10 @@ final readonly class Fingerprint
|
||||
$parts = [];
|
||||
|
||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
@@ -251,47 +203,9 @@ final readonly class Fingerprint
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function packageJsonHash(string $projectRoot): ?string
|
||||
private static function composerLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/package.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$relevant = [
|
||||
'type' => $data['type'] ?? null,
|
||||
'packageManager' => $data['packageManager'] ?? null,
|
||||
'dependencies' => $data['dependencies'] ?? null,
|
||||
'devDependencies' => $data['devDependencies'] ?? null,
|
||||
'optionalDependencies' => $data['optionalDependencies'] ?? null,
|
||||
'peerDependencies' => $data['peerDependencies'] ?? null,
|
||||
'overrides' => $data['overrides'] ?? null,
|
||||
'resolutions' => $data['resolutions'] ?? null,
|
||||
'imports' => $data['imports'] ?? null,
|
||||
'exports' => $data['exports'] ?? null,
|
||||
'browser' => $data['browser'] ?? null,
|
||||
];
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
|
||||
$json = json_encode($relevant);
|
||||
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
return self::trackedHash($projectRoot, 'composer.lock');
|
||||
}
|
||||
|
||||
private static function packageLockHash(string $projectRoot): ?string
|
||||
@@ -299,7 +213,11 @@ final readonly class Fingerprint
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
@@ -309,196 +227,78 @@ final readonly class Fingerprint
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function envFilesHash(string $projectRoot): ?string
|
||||
private static function lockfileHash(string $path, string $name): ?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])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$path] = true;
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = basename($path).':'.hash('xxh128', $contents);
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($parts);
|
||||
foreach (self::LOCKFILES as $class) {
|
||||
$handler = new $class;
|
||||
|
||||
return hash('xxh128', implode("\n", $parts));
|
||||
if (! $handler->applies($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
private static function composerJsonHash(string $projectRoot): ?string
|
||||
$fingerprint = $handler->fingerprint($contents);
|
||||
|
||||
if ($fingerprint !== null) {
|
||||
return $fingerprint;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return hash('xxh128', $contents);
|
||||
}
|
||||
|
||||
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.json';
|
||||
|
||||
if (! is_file($path)) {
|
||||
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||
$relevantConfig = array_intersect_key($config, [
|
||||
'platform' => true,
|
||||
'allow-plugins' => true,
|
||||
]);
|
||||
|
||||
$relevant = [
|
||||
'autoload' => $data['autoload'] ?? null,
|
||||
'autoload-dev' => $data['autoload-dev'] ?? null,
|
||||
'require' => $data['require'] ?? null,
|
||||
'require-dev' => $data['require-dev'] ?? null,
|
||||
'extra' => $data['extra'] ?? null,
|
||||
'repositories' => $data['repositories'] ?? null,
|
||||
'minimum-stability' => $data['minimum-stability'] ?? null,
|
||||
'prefer-stable' => $data['prefer-stable'] ?? null,
|
||||
'config' => $relevantConfig === [] ? null : $relevantConfig,
|
||||
];
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
|
||||
$json = json_encode($relevant);
|
||||
|
||||
return $json === false ? null : hash('xxh128', $json);
|
||||
}
|
||||
|
||||
private static function composerLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$path = $projectRoot.'/composer.lock';
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
$relevant = [
|
||||
'platform' => $data['platform'] ?? null,
|
||||
'platform-dev' => $data['platform-dev'] ?? null,
|
||||
];
|
||||
|
||||
foreach (['packages', 'packages-dev'] as $section) {
|
||||
if (! isset($data[$section])) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($data[$section])) {
|
||||
continue;
|
||||
}
|
||||
$packages = [];
|
||||
|
||||
foreach ($data[$section] as $package) {
|
||||
if (! is_array($package)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
ksort($packages);
|
||||
$relevant[$section] = $packages;
|
||||
}
|
||||
|
||||
self::sortRecursively($relevant);
|
||||
|
||||
$json = json_encode($relevant);
|
||||
|
||||
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;
|
||||
if (! is_file($projectRoot.'/'.$relativePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function sortRecursively(mixed &$value): void
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
static $cache = [];
|
||||
|
||||
$key = $projectRoot."\0".$relativePath;
|
||||
|
||||
if (isset($cache[$key])) {
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
$isAssoc = ! array_is_list($value);
|
||||
|
||||
if ($isAssoc) {
|
||||
ksort($value);
|
||||
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
|
||||
return $cache[$key] = true;
|
||||
}
|
||||
|
||||
foreach ($value as &$child) {
|
||||
self::sortRecursively($child);
|
||||
}
|
||||
$finder = (new Finder)
|
||||
->in($projectRoot)
|
||||
->depth('== 0')
|
||||
->name($relativePath)
|
||||
->ignoreVCSIgnored(true);
|
||||
|
||||
return $cache[$key] = $finder->hasResults();
|
||||
}
|
||||
|
||||
private static function contentHashOrNull(string $path): ?string
|
||||
@@ -522,66 +322,4 @@ final readonly class Fingerprint
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
// Only hashes `ext-*` entries declared in composer.json — incidental extensions loaded on the
|
||||
// machine but not declared can't affect suite correctness, so they're excluded to reduce noise.
|
||||
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));
|
||||
}
|
||||
|
||||
/** @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));
|
||||
}
|
||||
}
|
||||
|
||||
+439
-210
@@ -4,16 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\View;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use PHPUnit\TextUI\Configuration\Registry;
|
||||
|
||||
/**
|
||||
* Dependency graph: test file → set<source file>. Skips unchanged tests on replay.
|
||||
* Source files are indexed by numeric id to keep the on-disk JSON compact.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Graph
|
||||
@@ -48,12 +48,14 @@ final class Graph
|
||||
*/
|
||||
private array $baselines = [];
|
||||
|
||||
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
|
||||
private readonly string $projectRoot;
|
||||
|
||||
/** @var array<string, true>|null */
|
||||
private ?array $archTestFiles = null;
|
||||
|
||||
/** @var array<string, string|false> */
|
||||
private array $realpathCache = [];
|
||||
|
||||
public function __construct(string $projectRoot)
|
||||
{
|
||||
$real = @realpath($projectRoot);
|
||||
@@ -85,40 +87,83 @@ final class Graph
|
||||
*/
|
||||
public function affected(array $changedFiles): array
|
||||
{
|
||||
$normalised = [];
|
||||
[$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles);
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
$unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet);
|
||||
|
||||
[$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]
|
||||
= $this->applyInertiaChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$this->applyTestFileChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet);
|
||||
|
||||
$this->applyWatchPatternFallback(
|
||||
$nonMigrationPaths,
|
||||
$unparseableMigrations,
|
||||
$preciselyHandledPages,
|
||||
$sharedFilesResolved,
|
||||
$staticallyHandledBlade,
|
||||
$affectedSet,
|
||||
);
|
||||
|
||||
$this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet);
|
||||
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $changedFiles
|
||||
* @return array{0: list<string>, 1: list<string>}
|
||||
*/
|
||||
private function partitionChangedPaths(array $changedFiles): array
|
||||
{
|
||||
$migrations = [];
|
||||
$nonMigrations = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$normalised[] = $rel;
|
||||
}
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
// Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
|
||||
// every migration, so any migration change would re-run the whole DB suite. Route them via
|
||||
// table-intersection instead; unparseable migrations fall through to the watch pattern.
|
||||
$migrationPaths = [];
|
||||
$nonMigrationPaths = [];
|
||||
|
||||
foreach ($normalised as $rel) {
|
||||
if ($this->isMigrationPath($rel)) {
|
||||
$migrationPaths[] = $rel;
|
||||
$migrations[] = $rel;
|
||||
} else {
|
||||
$nonMigrationPaths[] = $rel;
|
||||
$nonMigrations[] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
return [$migrations, $nonMigrations];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $migrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return list<string> Unparseable migrations (caller treats as unknown-to-graph).
|
||||
*/
|
||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||
{
|
||||
// With no recorded table usage at all, table intersection can never
|
||||
// select anything — route every migration change through the
|
||||
// watch-pattern fallback instead of silently skipping tests.
|
||||
if ($this->testTables === []) {
|
||||
return $migrationPaths;
|
||||
}
|
||||
|
||||
$changedTables = [];
|
||||
$unparseableMigrations = [];
|
||||
$unparseable = [];
|
||||
|
||||
foreach ($migrationPaths as $rel) {
|
||||
$tables = $this->tablesForMigration($rel);
|
||||
|
||||
if ($tables === []) {
|
||||
$unparseableMigrations[] = $rel;
|
||||
$unparseable[] = $rel;
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -144,8 +189,17 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Inertia page routing: map changed page files to component names and intersect with recorded
|
||||
// component edges. Pages with no captured edges fall through to the watch pattern.
|
||||
return $unparseable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
|
||||
* globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved
|
||||
*/
|
||||
private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$globalFrontendRuntimeFiles = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
@@ -176,9 +230,8 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
|
||||
// Files absent from the map fall through to the watch pattern.
|
||||
$sharedFilesResolved = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
continue;
|
||||
@@ -186,12 +239,12 @@ final class Graph
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($this->jsFileToComponents[$rel])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$touchedAny = false;
|
||||
|
||||
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
|
||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||
$changedComponents[$pageComponent] = true;
|
||||
@@ -204,10 +257,8 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
|
||||
// import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
|
||||
// trustworthy enough to honour a negative (PHP parser can miss custom aliases).
|
||||
$newJsFiles = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
continue;
|
||||
@@ -228,42 +279,7 @@ final class Graph
|
||||
}
|
||||
|
||||
if ($newJsFiles !== []) {
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
|
||||
// knows precision was downgraded rather than leaving the slower replay unexplained.
|
||||
$output = Container::getInstance()->get(OutputInterface::class);
|
||||
if ($output instanceof OutputInterface) {
|
||||
$output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
count($newJsFiles),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
foreach ($newJsFiles as $rel) {
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
// Vite confirms no page imports this file — suppress the watch broadcast.
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$touchedAny = false;
|
||||
foreach ($pages as $pageComponent) {
|
||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||
$changedComponents[$pageComponent] = true;
|
||||
$touchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($touchedAny) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
|
||||
}
|
||||
|
||||
if ($changedComponents !== []) {
|
||||
@@ -282,8 +298,61 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
|
||||
// their always-on edges from re-running the whole DB suite.
|
||||
return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $newJsFiles
|
||||
* @param array<string, true> $changedComponents
|
||||
* @param array<string, true> $sharedFilesResolved
|
||||
*/
|
||||
private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void
|
||||
{
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
View::render('components.badge', [
|
||||
'type' => 'WARN',
|
||||
'content' => sprintf(
|
||||
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
count($newJsFiles),
|
||||
),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($newJsFiles as $rel) {
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$touchedAny = false;
|
||||
|
||||
foreach ($pages as $pageComponent) {
|
||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||
$changedComponents[$pageComponent] = true;
|
||||
$touchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($touchedAny) {
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return array<string, true> Unknown source dirs (sibling-heuristic).
|
||||
*/
|
||||
private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$changedIds = [];
|
||||
$unknownSourceDirs = [];
|
||||
$sourcePhpChanged = false;
|
||||
@@ -300,10 +369,7 @@ final class Graph
|
||||
}
|
||||
|
||||
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
||||
$absolute = $this->projectRoot.'/'.$rel;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Deleted source file unknown to the graph — no edge ever pointed to it.
|
||||
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -313,8 +379,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Arch tests inspect structure by namespace/path, never producing coverage edges for the files
|
||||
// they examine — so a new class can fail an arch expectation without any edge to it.
|
||||
if ($sourcePhpChanged) {
|
||||
foreach (array_keys($this->edges) as $testFile) {
|
||||
if ($this->isArchTestFile($testFile)) {
|
||||
@@ -337,9 +401,45 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
|
||||
// ancestors and invalidate only tests that covered them.
|
||||
$staticallyHandledBlade = [];
|
||||
return $unknownSourceDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A changed file inside the configured test suites is itself the unit of
|
||||
* work — always run it (new untracked tests, edited tests, renames).
|
||||
*
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
*/
|
||||
private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void
|
||||
{
|
||||
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($affectedSet[$rel])) {
|
||||
continue;
|
||||
}
|
||||
if (! $testPaths->isTestFile($rel)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||
continue;
|
||||
}
|
||||
$affectedSet[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered.
|
||||
*
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param array<string, true> $affectedSet
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||
{
|
||||
$staticallyHandled = [];
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($this->fileIds[$rel])) {
|
||||
continue;
|
||||
@@ -353,21 +453,39 @@ final class Graph
|
||||
|
||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||
|
||||
// Only a walk that actually selected tests counts as handled — a
|
||||
// component whose usage the static walk missed must still reach
|
||||
// the watch-pattern fallback instead of being silently swallowed.
|
||||
if ($bladeAffected !== []) {
|
||||
foreach ($bladeAffected as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
$staticallyHandled[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded
|
||||
// to avoid re-broadcasting via the watch pattern and defeating the surgical match.
|
||||
return $staticallyHandled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $nonMigrationPaths
|
||||
* @param list<string> $unparseableMigrations
|
||||
* @param array<string, true> $preciselyHandledPages
|
||||
* @param array<string, true> $sharedFilesResolved
|
||||
* @param array<string, true> $staticallyHandledBlade
|
||||
* @param array<string, true> $affectedSet
|
||||
*/
|
||||
private function applyWatchPatternFallback(
|
||||
array $nonMigrationPaths,
|
||||
array $unparseableMigrations,
|
||||
array $preciselyHandledPages,
|
||||
array $sharedFilesResolved,
|
||||
array $staticallyHandledBlade,
|
||||
array &$affectedSet,
|
||||
): void {
|
||||
$unknownToGraph = $unparseableMigrations;
|
||||
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
continue;
|
||||
@@ -380,7 +498,6 @@ final class Graph
|
||||
}
|
||||
if (! isset($this->fileIds[$rel])) {
|
||||
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||
// Deleted file unknown to the graph — no edge ever pointed to it.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -397,11 +514,18 @@ final class Graph
|
||||
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $unknownSourceDirs
|
||||
* @param array<string, true> $affectedSet
|
||||
*/
|
||||
private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void
|
||||
{
|
||||
if ($unknownSourceDirs === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from
|
||||
// another branch. Run tests that cover neighbouring files in the same directory so framework-
|
||||
// discovered files (Listeners, Events, Policies, etc.) aren't silently missed.
|
||||
if ($unknownSourceDirs !== []) {
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
if (isset($affectedSet[$testFile])) {
|
||||
continue;
|
||||
@@ -423,9 +547,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
public function knowsTest(string $testFile): bool
|
||||
{
|
||||
$rel = $this->relative($testFile);
|
||||
@@ -471,7 +592,8 @@ final class Graph
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'][$testId] = [
|
||||
|
||||
$entry = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'time' => $time,
|
||||
@@ -482,9 +604,11 @@ final class Graph
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$this->baselines[$branch]['results'][$testId]['file'] = $rel;
|
||||
$entry['file'] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
$this->baselines[$branch]['results'][$testId] = $entry;
|
||||
}
|
||||
|
||||
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
|
||||
@@ -508,9 +632,6 @@ final class Graph
|
||||
|
||||
$r = $baseline['results'][$testId];
|
||||
|
||||
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
|
||||
// each variant via its specific factory. Keeps the stored message
|
||||
// intact (important for skips/failures shown to the user).
|
||||
return match ($r['status']) {
|
||||
0 => TestStatus::success(),
|
||||
1 => TestStatus::skipped($r['message']),
|
||||
@@ -528,21 +649,21 @@ final class Graph
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function failedOrErroredTestFiles(string $branch, string $fallbackBranch = 'main'): array
|
||||
public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
$files = [];
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
$status = $result['status'] ?? null;
|
||||
|
||||
if ($status !== 7 && $status !== 8) {
|
||||
if (! $this->shouldRerun($result['status'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
if ($file === null) {
|
||||
continue;
|
||||
}
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -556,20 +677,18 @@ final class Graph
|
||||
return array_keys($files);
|
||||
}
|
||||
|
||||
public function hasUnlocatedFailuresOrErrors(string $branch, string $fallbackBranch = 'main'): bool
|
||||
public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
$status = $result['status'] ?? null;
|
||||
|
||||
if ($status !== 7 && $status !== 8) {
|
||||
if (! $this->shouldRerun($result['status'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '' || $this->relative($file) === null) {
|
||||
if ($file === null || $file === '' || $this->relative($file) === null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -577,6 +696,71 @@ final class Graph
|
||||
return false;
|
||||
}
|
||||
|
||||
private function shouldRerun(int $status): bool
|
||||
{
|
||||
return $this->shouldRerunStatus(TestStatus::from($status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cached result with this status must be re-executed rather
|
||||
* than replayed, honouring the configured failOn* / displayDetailsOn*
|
||||
* policies.
|
||||
*/
|
||||
public function shouldRerunStatus(TestStatus $testStatus): bool
|
||||
{
|
||||
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$configuration = Registry::get();
|
||||
|
||||
if ($testStatus->isRisky()) {
|
||||
return $configuration->failOnRisky();
|
||||
}
|
||||
|
||||
if ($testStatus->isWarning()) {
|
||||
if ($configuration->failOnWarning()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnTestsThatTriggerWarnings();
|
||||
}
|
||||
|
||||
if ($testStatus->isNotice()) {
|
||||
if ($configuration->failOnNotice()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnTestsThatTriggerNotices();
|
||||
}
|
||||
|
||||
if ($testStatus->isDeprecation()) {
|
||||
if ($configuration->failOnDeprecation()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
|
||||
}
|
||||
|
||||
if ($testStatus->isIncomplete()) {
|
||||
if ($configuration->failOnIncomplete()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnIncompleteTests();
|
||||
}
|
||||
|
||||
if ($testStatus->isSkipped()) {
|
||||
if ($configuration->failOnSkipped()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $configuration->displayDetailsOnSkippedTests();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tree project-relative path → content hash
|
||||
*/
|
||||
@@ -586,7 +770,6 @@ final class Graph
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
// Edges and tree snapshot stay intact; only the run-state is reset.
|
||||
public function clearResults(string $branch): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
@@ -642,11 +825,42 @@ final class Graph
|
||||
$this->link($testFile, $source);
|
||||
}
|
||||
|
||||
// Deduplicate ids for this test.
|
||||
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark test files that executed under a recorded coverage session as "known",
|
||||
* seeding an empty edge set for any that produced zero project-source edges.
|
||||
*
|
||||
* Without this, a test that covers no application source (e.g. a pure unit
|
||||
* test asserting on language primitives) never becomes an edge key, so
|
||||
* {@see self::knowsTest()} reports it as unknown and it re-runs on every TIA
|
||||
* run. Recording it with an empty edge set lets it be replayed/skipped while
|
||||
* unchanged; it is still re-run whenever its own file changes, via
|
||||
* {@see self::applyTestFileChanges()}.
|
||||
*
|
||||
* Must only be called from the recording path, where coverage was actually
|
||||
* collected — otherwise a missing edge set could mean "coverage was off",
|
||||
* not "genuinely covered nothing".
|
||||
*
|
||||
* @param array<int, string> $testFiles Absolute or project-relative test file paths.
|
||||
*/
|
||||
public function markKnownTestFiles(array $testFiles): void
|
||||
{
|
||||
foreach ($testFiles as $testFile) {
|
||||
$rel = $this->relative($testFile);
|
||||
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($this->edges[$rel])) {
|
||||
$this->edges[$rel] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $testToTables
|
||||
*/
|
||||
@@ -703,7 +917,6 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
|
||||
/**
|
||||
* @param array<string, array<int, string>> $fileToComponents
|
||||
*/
|
||||
@@ -770,7 +983,7 @@ final class Graph
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($rel, $prefix)) {
|
||||
if (str_starts_with($rel, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -808,7 +1021,7 @@ final class Graph
|
||||
foreach ($repo->getFilenames() as $filename) {
|
||||
$factory = $repo->get($filename);
|
||||
|
||||
if ($factory === null) {
|
||||
if (! $factory instanceof TestCaseFactory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -839,24 +1052,14 @@ final class Graph
|
||||
return $this->archTestFiles;
|
||||
}
|
||||
|
||||
private function methodHasGroup(object $method, string $group): bool
|
||||
private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
|
||||
{
|
||||
if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) {
|
||||
if (in_array($group, $method->groups, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($method->attributes as $attribute) {
|
||||
if (! is_object($attribute)) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'arguments')) {
|
||||
if ($attribute->name !== Group::class) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -992,10 +1195,11 @@ final class Graph
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file instanceof \SplFileInfo || ! $file->isFile()) {
|
||||
assert($file instanceof \SplFileInfo);
|
||||
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
if (! str_ends_with($path, '.blade.php')) {
|
||||
continue;
|
||||
@@ -1061,7 +1265,21 @@ final class Graph
|
||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||
$name = str_replace('/', '.', $tail);
|
||||
|
||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
||||
if ($name === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [$name, str_replace('_', '-', $name)];
|
||||
|
||||
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||
$base = substr($name, 0, -strlen('.index'));
|
||||
|
||||
$names[] = $base;
|
||||
$names[] = str_replace('_', '-', $base);
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -1082,7 +1300,6 @@ final class Graph
|
||||
return TableExtractor::fromMigrationSource($content);
|
||||
}
|
||||
|
||||
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
|
||||
private function componentForInertiaPage(string $rel): ?string
|
||||
{
|
||||
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
|
||||
@@ -1134,13 +1351,7 @@ final class Graph
|
||||
/** @param array<string, array<int, string>> $edges */
|
||||
private function anyTestUses(array $edges, string $component): bool
|
||||
{
|
||||
foreach ($edges as $components) {
|
||||
if (in_array($component, $components, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
|
||||
}
|
||||
|
||||
public function pruneMissingTests(): void
|
||||
@@ -1166,6 +1377,51 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune baseline result entries whose test files were just executed but whose
|
||||
* test IDs are no longer present (e.g. the test method was removed or renamed).
|
||||
*
|
||||
* @param array<int, string> $touchedFiles Absolute or project-relative paths.
|
||||
* @param array<int, string> $keepTestIds Test IDs that produced a result this run.
|
||||
*/
|
||||
public function pruneStaleResults(string $branch, array $touchedFiles, array $keepTestIds): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch]['results'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$touched = [];
|
||||
foreach ($touchedFiles as $file) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$touched[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($touched === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keep = array_fill_keys($keepTestIds, true);
|
||||
|
||||
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($touched[$file])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($keep[$testId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->baselines[$branch]['results'][$testId]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function decode(string $json, string $projectRoot): ?self
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
@@ -1181,78 +1437,51 @@ final class Graph
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
|
||||
if (isset($data['test_tables']) && is_array($data['test_tables'])) {
|
||||
foreach ($data['test_tables'] as $testRel => $tables) {
|
||||
if (! is_string($testRel)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($tables)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (is_string($table) && $table !== '') {
|
||||
$names[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$graph->testTables[$testRel] = $names;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
|
||||
foreach ($data['test_inertia_components'] as $testRel => $components) {
|
||||
if (! is_string($testRel)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (is_string($component) && $component !== '') {
|
||||
$names[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$graph->testInertiaComponents[$testRel] = $names;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
|
||||
foreach ($data['js_file_to_components'] as $path => $components) {
|
||||
if (! is_string($path)) {
|
||||
continue;
|
||||
}
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (is_string($component) && $component !== '') {
|
||||
$names[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$graph->jsFileToComponents[$path] = $names;
|
||||
}
|
||||
}
|
||||
}
|
||||
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
||||
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
||||
$graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null);
|
||||
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private static function decodeStringMap(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($section as $key => $values) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
if (is_string($value) && $value !== '') {
|
||||
$names[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
$out[$key] = $names;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function encode(): ?string
|
||||
{
|
||||
$payload = [
|
||||
@@ -1271,8 +1500,6 @@ final class Graph
|
||||
return $json === false ? null : $json;
|
||||
}
|
||||
|
||||
// Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
|
||||
// Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
|
||||
private function relative(string $path): ?string
|
||||
{
|
||||
if ($path === '' || $path === 'unknown') {
|
||||
@@ -1286,10 +1513,13 @@ final class Graph
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive
|
||||
|
||||
|| (strlen($path) >= 2 && $path[1] === ':');
|
||||
if ($isAbsolute) {
|
||||
$real = @realpath($path);
|
||||
if (array_key_exists($path, $this->realpathCache)) {
|
||||
$real = $this->realpathCache[$path];
|
||||
} else {
|
||||
$real = $this->realpathCache[$path] = @realpath($path);
|
||||
}
|
||||
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
@@ -1299,7 +1529,6 @@ final class Graph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always forward slashes — git always uses them; Windows backslashes would never match.
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
} else {
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+107
-257
@@ -8,108 +8,52 @@ 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.
|
||||
*
|
||||
* Backed by a Node helper (`bin/pest-tia-vite-deps.mjs`) that boots 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 exact graph Vite itself would use.
|
||||
*
|
||||
* Two latency mitigations:
|
||||
*
|
||||
* 1. **Content-hash cache** keyed by every file under `resources/js/`
|
||||
* (path + size + mtime) plus the bytes of `vite.config.*` and the
|
||||
* pages-directory casing. When inputs are unchanged, the 13s+ Node
|
||||
* bootstrap is skipped entirely and the previous result is reused.
|
||||
*
|
||||
* 2. **Background warmer** — `warmInBackground()` is called at suite
|
||||
* start. It computes the fingerprint, checks the cache, and only
|
||||
* spawns Node if a refresh is needed. The subprocess runs in
|
||||
* parallel with the test suite. By the time `build()` is called at
|
||||
* flush time, the result is usually already on stdout — `wait()`
|
||||
* returns instantly. If tests finish faster than Vite boots,
|
||||
* `build()` simply pays the remainder, never the full bootstrap.
|
||||
*
|
||||
* Callers invoke `build()` 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';
|
||||
|
||||
/** Active warmer subprocess, or null when none is in flight. */
|
||||
private static ?Process $warmer = null;
|
||||
|
||||
/** Fingerprint the warmer was started against — used to detect drift between warm and build. */
|
||||
private static ?string $warmerFingerprint = null;
|
||||
|
||||
/** True when the warmer found a fresh cache and skipped spawning Node. */
|
||||
private static bool $warmerCacheHit = false;
|
||||
|
||||
/** Project root the warmer was launched for. */
|
||||
private static ?string $warmerProjectRoot = null;
|
||||
|
||||
/**
|
||||
* Kicks off the Node helper in the background, so by the time
|
||||
* `build()` is called at flush time the result is (usually) already
|
||||
* sitting on stdout. Idempotent — a second call while a warmer is
|
||||
* already in flight is a no-op. Cheap when the cache is fresh: it
|
||||
* checks the fingerprint first and skips the subprocess.
|
||||
*
|
||||
* Safe to call from any TIA entry point that will eventually write
|
||||
* the graph from the main process. Workers must NOT call this — they
|
||||
* don't flush the graph and would duplicate the Node bootstrap on
|
||||
* every worker.
|
||||
* @var list<string>
|
||||
*/
|
||||
public static function warmInBackground(string $projectRoot): void
|
||||
{
|
||||
if (self::$warmer !== null || self::$warmerCacheHit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::isApplicable($projectRoot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fingerprint = self::fingerprint($projectRoot);
|
||||
|
||||
if ($fingerprint !== null && self::readCache($projectRoot, $fingerprint) !== null) {
|
||||
self::$warmerCacheHit = true;
|
||||
self::$warmerFingerprint = $fingerprint;
|
||||
self::$warmerProjectRoot = $projectRoot;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$process = self::buildNodeProcess($projectRoot);
|
||||
|
||||
if ($process === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$process->start();
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$warmer = $process;
|
||||
self::$warmerFingerprint = $fingerprint;
|
||||
self::$warmerProjectRoot = $projectRoot;
|
||||
|
||||
register_shutdown_function(self::reapWarmer(...));
|
||||
}
|
||||
public const array VITE_CONFIG_NAMES = [
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>> project-relative source path → sorted list of page component names
|
||||
* 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
|
||||
{
|
||||
@@ -119,13 +63,6 @@ final class JsModuleGraph
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict variant — returns null when the Node resolver isn't
|
||||
* available, so callers can distinguish "Vite says nothing imports
|
||||
* this file" (empty list) from "we couldn't ask Vite" (null).
|
||||
*
|
||||
* 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").
|
||||
*
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
public static function buildStrict(string $projectRoot): ?array
|
||||
@@ -133,24 +70,50 @@ final class JsModuleGraph
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -169,79 +132,13 @@ final class JsModuleGraph
|
||||
$cached = self::readCache($projectRoot, $fingerprint);
|
||||
|
||||
if ($cached !== null) {
|
||||
self::reapWarmer();
|
||||
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Pick up the warmer when it was launched against the same
|
||||
// fingerprint and project root. Drift between warm and build
|
||||
// (rare — would require a JS file to change mid-test-run)
|
||||
// discards the warmer and re-runs synchronously.
|
||||
if (self::$warmerCacheHit
|
||||
&& self::$warmerFingerprint === $fingerprint
|
||||
&& self::$warmerProjectRoot === $projectRoot
|
||||
&& $fingerprint !== null) {
|
||||
$cached = self::readCache($projectRoot, $fingerprint);
|
||||
self::$warmerCacheHit = false;
|
||||
self::$warmerFingerprint = null;
|
||||
self::$warmerProjectRoot = null;
|
||||
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$warmer !== null
|
||||
&& self::$warmerFingerprint === $fingerprint
|
||||
&& self::$warmerProjectRoot === $projectRoot) {
|
||||
$process = self::$warmer;
|
||||
self::$warmer = null;
|
||||
self::$warmerFingerprint = null;
|
||||
self::$warmerProjectRoot = null;
|
||||
|
||||
try {
|
||||
$process->wait();
|
||||
} catch (\Throwable) {
|
||||
// fall through to synchronous run
|
||||
$process = null;
|
||||
}
|
||||
|
||||
if ($process !== null && $process->isSuccessful()) {
|
||||
$result = self::parseNodeOutput($process->getOutput());
|
||||
|
||||
if ($result !== null) {
|
||||
if ($fingerprint !== null) {
|
||||
self::writeCache($projectRoot, $fingerprint, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Different fingerprint or different project root: discard
|
||||
// any stale warmer before we start a fresh run.
|
||||
self::reapWarmer();
|
||||
}
|
||||
|
||||
$viaNode = self::runNodeSync($projectRoot);
|
||||
|
||||
if ($viaNode !== null && $fingerprint !== null) {
|
||||
self::writeCache($projectRoot, $fingerprint, $viaNode);
|
||||
}
|
||||
|
||||
return $viaNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function runNodeSync(string $projectRoot): ?array
|
||||
{
|
||||
$process = self::buildNodeProcess($projectRoot);
|
||||
|
||||
if ($process === null) {
|
||||
if (! $process instanceof Process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -251,7 +148,13 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::parseNodeOutput($process->getOutput());
|
||||
$result = self::parseNodeOutput($process->getOutput());
|
||||
|
||||
if ($result !== null && $fingerprint !== null) {
|
||||
self::writeCache($projectRoot, $fingerprint, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function buildNodeProcess(string $projectRoot): ?Process
|
||||
@@ -260,7 +163,9 @@ final class JsModuleGraph
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -276,21 +181,7 @@ 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);
|
||||
|
||||
return $process;
|
||||
@@ -336,56 +227,11 @@ final class JsModuleGraph
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and discard a leftover warmer subprocess (e.g. on shutdown,
|
||||
* or when `build()` resolved from cache without needing the warmer).
|
||||
*/
|
||||
private static function reapWarmer(): void
|
||||
{
|
||||
$process = self::$warmer;
|
||||
self::$warmer = null;
|
||||
self::$warmerFingerprint = null;
|
||||
self::$warmerProjectRoot = null;
|
||||
self::$warmerCacheHit = false;
|
||||
|
||||
if ($process === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($process->isRunning()) {
|
||||
$process->stop(0.5);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content fingerprint of every input that can change the Node/Vite
|
||||
* module graph: each `resources/js/**` source (path + size + mtime),
|
||||
* each `vite.config.*` (path + size + mtime + sha-of-bytes), and
|
||||
* the chosen pages-directory casing. Returns null only when no
|
||||
* `vite.config.*` exists — i.e. the resolver itself wouldn't run.
|
||||
*
|
||||
* File inputs use `mtime+size` rather than full content hashes —
|
||||
* walking thousands of SFCs and re-hashing them on every flush
|
||||
* would defeat the point of the cache. mtime/size collisions on
|
||||
* an edited file are theoretically possible but vanishingly rare,
|
||||
* and the cost of a rare miss (one extra Node run) is exactly what
|
||||
* the cache costs anyway. The vite config itself is small and
|
||||
* load-bearing for plugin/alias behaviour, so we hash its bytes
|
||||
* outright.
|
||||
*/
|
||||
private static function fingerprint(string $projectRoot): ?string
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (! is_file($path)) {
|
||||
@@ -401,17 +247,25 @@ final class JsModuleGraph
|
||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
||||
}
|
||||
|
||||
foreach (['Pages', 'pages'] as $dir) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
$parts[] = 'pagesDir:'.$dir;
|
||||
|
||||
break;
|
||||
}
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
|
||||
$override = getenv('TIA_VITE_PAGES_DIR');
|
||||
|
||||
if (is_dir($jsRoot)) {
|
||||
if (is_string($override) && $override !== '') {
|
||||
$parts[] = 'pagesDirOverride:'.$override;
|
||||
}
|
||||
|
||||
$pagesDir = self::firstExistingPagesDir($projectRoot);
|
||||
|
||||
if ($pagesDir !== null) {
|
||||
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
|
||||
}
|
||||
|
||||
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
|
||||
|
||||
if ($jsRoot !== null && is_dir($jsRoot)) {
|
||||
$entries = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
@@ -475,10 +329,12 @@ final class JsModuleGraph
|
||||
$out = [];
|
||||
|
||||
foreach ($graph as $key => $value) {
|
||||
if (! is_string($key) || ! is_array($value)) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($value as $name) {
|
||||
@@ -532,12 +388,6 @@ final class JsModuleGraph
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
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/'));
|
||||
}
|
||||
}
|
||||
+78
-390
@@ -8,9 +8,6 @@ use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state
|
||||
* shared across the `Prepared` and `Finished` subscribers.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Recorder
|
||||
@@ -35,33 +32,30 @@ final class Recorder
|
||||
/** @var array<string, bool> */
|
||||
private array $classUsesDatabaseCache = [];
|
||||
|
||||
// Source file → declared class names. Built incrementally as classes are autoloaded.
|
||||
// Used to walk the interface/trait/parent hierarchy which coverage drivers miss
|
||||
// (interfaces and empty traits emit no executable bytecode).
|
||||
/** @var array<string, list<string>> */
|
||||
private array $fileToClassNames = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $indexedClassNames = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $classDependencyCache = [];
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $testImportFileCache = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $includedFilesAtTestStart = [];
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $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;
|
||||
}
|
||||
@@ -78,8 +72,6 @@ final class Recorder
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
// Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents
|
||||
// (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent.
|
||||
$modes = \xdebug_info('mode');
|
||||
|
||||
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
||||
@@ -94,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;
|
||||
}
|
||||
|
||||
@@ -118,16 +107,14 @@ final class Recorder
|
||||
}
|
||||
|
||||
$this->currentTestFile = $file;
|
||||
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
|
||||
|
||||
if ($this->classUsesDatabase($className)) {
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
// Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only
|
||||
// TestCase) emit no executable bytecode, so the coverage driver never records them.
|
||||
$this->linkAncestorFiles($className);
|
||||
$this->linkImportedFiles($file);
|
||||
if (! $this->captureCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
@@ -136,60 +123,58 @@ 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();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\all);
|
||||
|
||||
// pcov returns every executable line in every file it
|
||||
// tracked: positive values for executed lines, `-1` for
|
||||
// executable-but-not-run. A file with no positives was
|
||||
// loaded but nothing in it ran during this test's window
|
||||
// — typically a declaration-only file (Mailables, Enums,
|
||||
// DTOs) pulled in by some service-provider's static `use`
|
||||
// at framework boot. Including those attributes every
|
||||
// globally-bootstrapped class to whichever test triggered
|
||||
// the boot, blowing up the affected set on edits to those
|
||||
// files.
|
||||
$coveredFiles = self::filesWithExecutedLines($data);
|
||||
$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, $filesToCollectCoverageFor);
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$coveredFiles = array_keys($data);
|
||||
$scope = $this->sourceScope();
|
||||
|
||||
foreach (array_keys($data) as $file) {
|
||||
if (! $scope->contains($file)) {
|
||||
unset($data[$file]);
|
||||
}
|
||||
}
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
}
|
||||
|
||||
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 covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
|
||||
// so a signature change would leave implementing-class tests stale without this walk.
|
||||
$this->linkSourceDependencies($coveredFiles);
|
||||
|
||||
$this->currentTestFile = null;
|
||||
$this->includedFilesAtTestStart = [];
|
||||
}
|
||||
|
||||
public function linkSource(string $sourceFile): void
|
||||
@@ -209,294 +194,6 @@ final class Recorder
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
/** @param iterable<int, string> $sourceFiles */
|
||||
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($testFile === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($sourceFiles as $sourceFile) {
|
||||
if ($sourceFile === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->perTestFiles[$testFile][$sourceFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, string> $coveredFiles */
|
||||
private function linkSourceDependencies(array $coveredFiles): void
|
||||
{
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refreshClassMap();
|
||||
|
||||
foreach ($coveredFiles as $coveredFile) {
|
||||
if (! isset($this->fileToClassNames[$coveredFile])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->fileToClassNames[$coveredFile] as $name) {
|
||||
foreach ($this->classDependencies($name) as $depFile) {
|
||||
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function refreshClassMap(): void
|
||||
{
|
||||
$names = array_merge(
|
||||
get_declared_classes(),
|
||||
get_declared_interfaces(),
|
||||
get_declared_traits(),
|
||||
);
|
||||
|
||||
foreach ($names as $name) {
|
||||
if (isset($this->indexedClassNames[$name])) {
|
||||
continue;
|
||||
}
|
||||
$this->indexedClassNames[$name] = true;
|
||||
|
||||
if (! class_exists($name, false)
|
||||
&& ! interface_exists($name, false)
|
||||
&& ! trait_exists($name, false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($name);
|
||||
|
||||
if ($reflection->isInternal()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $reflection->getFileName();
|
||||
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->fileToClassNames[$file][] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function classDependencies(string $className): array
|
||||
{
|
||||
if (isset($this->classDependencyCache[$className])) {
|
||||
return $this->classDependencyCache[$className];
|
||||
}
|
||||
|
||||
if (! class_exists($className, false)
|
||||
&& ! interface_exists($className, false)
|
||||
&& ! trait_exists($className, false)) {
|
||||
return $this->classDependencyCache[$className] = [];
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
$files = [];
|
||||
|
||||
$linkSymbol = static function (string $name) use (&$files): void {
|
||||
if (! class_exists($name, false)
|
||||
&& ! interface_exists($name, false)
|
||||
&& ! trait_exists($name, false)) {
|
||||
return;
|
||||
}
|
||||
$r = new ReflectionClass($name);
|
||||
if ($r->isInternal()) {
|
||||
return;
|
||||
}
|
||||
$f = $r->getFileName();
|
||||
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
return;
|
||||
}
|
||||
$files[$f] = true;
|
||||
};
|
||||
|
||||
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
|
||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
||||
$linkSymbol($iname);
|
||||
}
|
||||
|
||||
foreach ($reflection->getTraitNames() as $tname) {
|
||||
$linkSymbol($tname);
|
||||
}
|
||||
|
||||
$parent = $reflection->getParentClass();
|
||||
while ($parent !== false && ! $parent->isInternal()) {
|
||||
$f = $parent->getFileName();
|
||||
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
$files[$f] = true;
|
||||
}
|
||||
foreach ($parent->getTraitNames() as $tname) {
|
||||
$linkSymbol($tname);
|
||||
}
|
||||
$parent = $parent->getParentClass();
|
||||
}
|
||||
|
||||
return $this->classDependencyCache[$className] = array_keys($files);
|
||||
}
|
||||
|
||||
private function linkAncestorFiles(string $className): void
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
$parent = $reflection->getParentClass();
|
||||
|
||||
while ($parent !== false) {
|
||||
if ($parent->isInternal()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$file = $parent->getFileName();
|
||||
|
||||
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
|
||||
}
|
||||
|
||||
$parent = $parent->getParentClass();
|
||||
}
|
||||
}
|
||||
|
||||
private function linkImportedFiles(string $testFile): void
|
||||
{
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->importedFilesFor($testFile) as $file) {
|
||||
$this->perTestFiles[$this->currentTestFile][$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function importedFilesFor(string $testFile): array
|
||||
{
|
||||
if (array_key_exists($testFile, $this->testImportFileCache)) {
|
||||
return $this->testImportFileCache[$testFile];
|
||||
}
|
||||
|
||||
$source = @file_get_contents($testFile);
|
||||
if ($source === false) {
|
||||
return $this->testImportFileCache[$testFile] = [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
foreach ($this->importedClassNames($source) as $className) {
|
||||
$file = $this->findAutoloadFile($className);
|
||||
|
||||
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||
$files[$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->testImportFileCache[$testFile] = array_keys($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function importedClassNames(string $source): array
|
||||
{
|
||||
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
|
||||
|
||||
$classes = [];
|
||||
|
||||
foreach ($matches[1] as $import) {
|
||||
$import = trim($import);
|
||||
|
||||
if ($import === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$open = strpos($import, '{');
|
||||
$close = strrpos($import, '}');
|
||||
|
||||
if ($open !== false && $close !== false && $close > $open) {
|
||||
$prefix = trim(trim(substr($import, 0, $open)), '\\');
|
||||
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
|
||||
|
||||
foreach ($items as $item) {
|
||||
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
|
||||
|
||||
if ($class !== null) {
|
||||
$classes[$class] = true;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$class = $this->normaliseImportedClass($import);
|
||||
|
||||
if ($class !== null) {
|
||||
$classes[$class] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($classes);
|
||||
}
|
||||
|
||||
private function normaliseImportedClass(string $import): ?string
|
||||
{
|
||||
$import = trim(trim($import), '\\');
|
||||
|
||||
if ($import === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+as\s+/i', $import);
|
||||
if ($parts === false || $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;
|
||||
}
|
||||
|
||||
private function classUsesDatabase(string $className): bool
|
||||
{
|
||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
||||
@@ -628,64 +325,56 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName()
|
||||
// (which returns 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters pcov's `file => line => executionCount` map to the files
|
||||
* that actually had at least one executed line. pcov reports `-1`
|
||||
* for "executable but not run" and a positive count for executed
|
||||
* lines; a file with no positives was loaded but contributed no
|
||||
* executed code to this test.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function filesWithExecutedLines(array $data): array
|
||||
private function filesWithExecutedLines(array $data): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $file => $lines) {
|
||||
if (! is_string($file) || ! is_array($lines)) {
|
||||
if (! is_array($lines)) {
|
||||
continue;
|
||||
}
|
||||
$covered = [];
|
||||
foreach ($lines as $line => $count) {
|
||||
if (is_int($count) && $count > 0) {
|
||||
$covered[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if ($covered === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($lines as $count) {
|
||||
if (is_int($count) && $count > 0) {
|
||||
$out[] = $file;
|
||||
$lineKeys = array_keys($lines);
|
||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$out[] = $file;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function sourceScope(): SourceScope
|
||||
{
|
||||
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentTestFile = null;
|
||||
@@ -695,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,11 +4,9 @@ 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
|
||||
@@ -37,7 +35,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(0, '');
|
||||
$this->record(TestStatus::success());
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
@@ -46,7 +44,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(7, $message);
|
||||
$this->record(TestStatus::failure($message));
|
||||
}
|
||||
|
||||
public function testErrored(string $message): void
|
||||
@@ -55,7 +53,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(8, $message);
|
||||
$this->record(TestStatus::error($message));
|
||||
}
|
||||
|
||||
public function testSkipped(string $message): void
|
||||
@@ -64,7 +62,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(1, $message);
|
||||
$this->record(TestStatus::skipped($message));
|
||||
}
|
||||
|
||||
public function testIncomplete(string $message): void
|
||||
@@ -73,7 +71,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(2, $message);
|
||||
$this->record(TestStatus::incomplete($message));
|
||||
}
|
||||
|
||||
public function testRisky(string $message): void
|
||||
@@ -82,7 +80,7 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(5, $message);
|
||||
$this->record(TestStatus::risky($message));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,10 +99,6 @@ 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, file?: string}> $results
|
||||
*/
|
||||
public function merge(array $results): void
|
||||
@@ -122,11 +116,6 @@ final class ResultCollector
|
||||
$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;
|
||||
@@ -134,7 +123,7 @@ final class ResultCollector
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function record(int $status, string $message): void
|
||||
private function record(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
@@ -144,15 +133,11 @@ 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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,15 +25,6 @@ final class Storage
|
||||
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes the on-disk state directory for `$projectRoot`. Called by
|
||||
* `--fresh` so a rebuild starts from a truly empty cache: no stale
|
||||
* baseline, no leftover worker partials, no fingerprint, no JS
|
||||
* module cache. Subsequent writes recreate the directory on demand.
|
||||
*
|
||||
* Per-project (project key is part of the path) — sibling projects'
|
||||
* caches under `~/.pest/tia/` are untouched.
|
||||
*/
|
||||
public static function purge(string $projectRoot): void
|
||||
{
|
||||
$dir = self::tempDir($projectRoot);
|
||||
@@ -80,10 +45,12 @@ final class Storage
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
@@ -98,11 +65,6 @@ final class Storage
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private static function homeDir(): ?string
|
||||
{
|
||||
foreach (['HOME', 'USERPROFILE'] as $key) {
|
||||
@@ -117,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
|
||||
{
|
||||
@@ -152,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);
|
||||
@@ -176,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);
|
||||
}
|
||||
|
||||
@@ -202,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,33 +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',
|
||||
// Static public assets can affect browser-rendered pages without
|
||||
// any PHP file changing (favicons, robots, images, downloaded
|
||||
// manifests, etc.). Only browser-test targets are invalidated.
|
||||
'public/**/*.js',
|
||||
'public/**/*.css',
|
||||
'public/**/*.svg',
|
||||
'public/**/*.png',
|
||||
'public/**/*.jpg',
|
||||
'public/**/*.jpeg',
|
||||
'public/**/*.webp',
|
||||
'public/**/*.ico',
|
||||
'public/**/*.txt',
|
||||
'public/**/*.json',
|
||||
'public/**/*.xml',
|
||||
'public/hot',
|
||||
'resources/js/** !*.php',
|
||||
'resources/css/** !*.php',
|
||||
'public/hot !*.php',
|
||||
'public/** !*.php',
|
||||
];
|
||||
|
||||
$patterns = [];
|
||||
@@ -84,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,74 +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],
|
||||
|
||||
// Factories — loaded lazily but still PHP that coverage may miss
|
||||
// if the factory file was already autoloaded before Prepared.
|
||||
'database/factories/**/*.php' => [$testPath],
|
||||
|
||||
// Project fixture data. Laravel apps often keep fake repository
|
||||
// lockfiles / API payloads here and read them via `storage_path()`
|
||||
// + `file_get_contents()`, which neither PHP coverage nor static
|
||||
// import edges can observe.
|
||||
'storage/fixtures/**/*' => [$testPath],
|
||||
|
||||
// Non-PHP templates/data living beside app code. These are often
|
||||
// read dynamically by services (Dockerfile templates, stubs,
|
||||
// payload examples) and never appear in coverage because PHP only
|
||||
// sees the reader method, not the external file.
|
||||
'app/**/*.tpl' => [$testPath],
|
||||
'app/**/*.stub' => [$testPath],
|
||||
'app/**/*.json' => [$testPath],
|
||||
'app/**/*.yaml' => [$testPath],
|
||||
'app/**/*.yml' => [$testPath],
|
||||
'app/**/*.txt' => [$testPath],
|
||||
'app/** !*.php' => [$testPath],
|
||||
|
||||
// Blade templates — compiled to cache, source file not executed.
|
||||
'resources/views/**/*.blade.php' => [$testPath],
|
||||
// Mail / view-adjacent themes can be read dynamically by
|
||||
// mailables (for example Laravel's markdown mail theme CSS).
|
||||
'resources/views/**/*.css' => [$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],
|
||||
|
||||
// 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 — data/source snippets consumed by assertions or
|
||||
// external analysers. Nested `Fixtures/` directories are common
|
||||
// beside a single test class, and PHP fixtures may be parsed by
|
||||
// tools without being `require`d, so coverage cannot see them.
|
||||
$testPath.'/Fixtures/**/*' => [$testPath],
|
||||
$testPath.'/**/Fixtures/**/*' => [$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,23 +24,26 @@ final class WatchPatterns
|
||||
WatchDefaults\Browser::class,
|
||||
];
|
||||
|
||||
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
|
||||
|
||||
/**
|
||||
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files
|
||||
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
|
||||
*/
|
||||
private array $patterns = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
|
||||
*/
|
||||
private array $parsed = [];
|
||||
|
||||
private bool $enabled = false;
|
||||
|
||||
private bool $locally = false;
|
||||
|
||||
private bool $filtered = false;
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
private bool $baselined = false;
|
||||
|
||||
public function useDefaults(string $projectRoot): void
|
||||
{
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
@@ -66,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.
|
||||
@@ -106,22 +89,21 @@ final class WatchPatterns
|
||||
$matched = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
foreach ($this->patterns as $glob => $dirs) {
|
||||
if ($this->globMatches($glob, $file)) {
|
||||
foreach ($this->patterns as $key => $dirs) {
|
||||
if (! $this->keyMatches($key, $file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
$matched[$dir] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
@@ -185,19 +167,116 @@ final class WatchPatterns
|
||||
return $this->filtered;
|
||||
}
|
||||
|
||||
public function markBaselined(): void
|
||||
{
|
||||
$this->baselined = true;
|
||||
}
|
||||
|
||||
public function isBaselined(): bool
|
||||
{
|
||||
return $this->baselined;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->patterns = [];
|
||||
$this->parsed = [];
|
||||
$this->enabled = false;
|
||||
$this->locally = false;
|
||||
$this->filtered = false;
|
||||
$this->baselined = false;
|
||||
}
|
||||
|
||||
private function keyMatches(string $key, string $file): bool
|
||||
{
|
||||
$rule = $this->parse($key);
|
||||
|
||||
if (! $this->globMatches($rule['include'], $file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$file = str_replace('\\', '/', $file);
|
||||
|
||||
if ($this->touchesVcs($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($rule['excludes'] as $exclude) {
|
||||
if ($this->excludeMatches($exclude, $file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,25 +6,17 @@ namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
use PHPUnit\Event\Test\PreparationStarted;
|
||||
use PHPUnit\Event\Test\PreparationStartedSubscriber;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
|
||||
+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 = (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));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user