Compare commits

..

164 Commits

Author SHA1 Message Date
3d5bba93f8 Bump shivammathur/setup-php in the github-actions group (#1706)
Bumps the github-actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.0 to 2.37.1
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](accd6127cb...7c071dfe9d)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:55:13 +01:00
79bc7a8257 Merge branch '4.x' into 5.x 2026-06-01 07:09:57 +01:00
40b88b62ef release: v4.7.2 2026-06-01 07:08:59 +01:00
e3361bc321 fix: don't ob_start with pao 2026-06-01 07:08:50 +01:00
fc48c1bd1e Merge branch '4.x' into 5.x 2026-06-01 06:33:35 +01:00
92e76eb5ab ci: runs ci only against stable 2026-06-01 06:32:29 +01:00
da726beffc chore: merges 4.x 2026-06-01 06:28:44 +01:00
4ef12b9aac Merge branch '4.x' into 5.x 2026-06-01 06:25:56 +01:00
bd22f478b8 chore: fixes issues with contracts and symfony 8.1 2026-06-01 06:24:42 +01:00
eeaac34cf6 release: v4.7.1 2026-06-01 05:42:12 +01:00
b9b07d8983 chore: bump dependencies 2026-06-01 05:42:03 +01:00
6aa7d2f891 fix: better fatal exceptions reporting 2026-06-01 05:41:58 +01:00
4d550cecfd Merge branch '4.x' into 5.x 2026-05-13 12:20:46 +01:00
1c21a7647a chore: fixes types 2026-05-13 12:20:00 +01:00
d649de1988 chore: add security policy 2026-05-12 02:48:25 +01:00
783ca4bcd6 chore(deps): limit dependabot to maintained branches (4.x + 5.x) 2026-05-12 02:34:08 +01:00
ba07497219 chore: enable Dependabot version updates for GitHub Actions (#1700) 2026-05-11 22:12:07 -03:00
34695843b3 chore: pin GitHub Actions to commit SHAs (#1699)
* chore: pin GitHub Actions to commit SHAs

* chore: pin GitHub Actions to commit SHAs
2026-05-11 22:12:04 -03:00
1ca021dea6 chore: pin GitHub Actions to commit SHAs (#1695)
* chore: pin GitHub Actions to commit SHAs

* chore: pin GitHub Actions to commit SHAs
2026-05-12 02:08:47 +01:00
d17be9decd types 2026-05-04 08:02:09 -03:00
b828ddcec7 chore: style 2026-05-04 07:38:50 -03:00
f859bb179d Merge branch '4.x' into 5.x 2026-05-04 07:38:40 -03:00
2fc75cfcf0 chore: updates snapshots 2026-05-03 13:09:32 -03:00
6cc48f63f8 chore: style 2026-05-03 13:06:24 -03:00
e0419d1328 release: v4.7.0 2026-05-03 12:46:24 -03:00
faa6988801 Merge pull request #1682 from pestphp/feat/tia
[4.x] TIA Engine
2026-05-03 16:27:58 +01:00
c12247fafd Revert "wip"
This reverts commit 1b168aba1c.
2026-05-03 11:45:39 -03:00
29b4452443 wip 2026-05-03 11:33:30 -03:00
1b168aba1c wip 2026-05-03 11:30:13 -03:00
6aabd977cd wip 2026-05-03 11:01:52 -03:00
a882543c53 wip 2026-05-03 10:57:28 -03:00
c250b9da4f wip 2026-05-03 10:37:17 -03:00
46bc3dc628 wip 2026-05-03 10:34:44 -03:00
d3ce498b8a wip 2026-05-03 10:31:47 -03:00
e1a4b98b71 wip 2026-05-03 10:16:10 -03:00
9afbcd5c18 wuip 2026-05-03 09:54:02 -03:00
75593b6454 wip 2026-05-03 13:37:27 +01:00
89590d6120 wip 2026-05-03 13:35:01 +01:00
fb0978c9bf wip 2026-05-03 13:26:48 +01:00
a3796daa42 wip 2026-05-03 13:24:10 +01:00
e3004db666 wip 2026-05-03 12:43:38 +01:00
99cc4e0146 wip 2026-05-02 19:33:09 +01:00
a47e6f8fef wip 2026-05-02 19:30:14 +01:00
536d79f765 wip 2026-05-02 19:20:55 +01:00
65c0fbc528 wip 2026-05-02 19:07:41 +01:00
9e4cf4b665 wip 2026-05-02 18:58:42 +01:00
7bea819978 wip 2026-05-02 18:47:26 +01:00
4280233b40 wip 2026-05-02 18:37:24 +01:00
d6db3a8a20 wip 2026-05-02 18:32:05 +01:00
51c8ce4df6 wip 2026-05-02 18:31:32 +01:00
5b8393b925 wip 2026-05-02 18:25:41 +01:00
e4d9b61fdf wip 2026-05-02 18:25:27 +01:00
e2d940cd53 wip 2026-05-02 18:25:21 +01:00
380ccd30b4 wip 2026-05-02 18:03:25 +01:00
31c200716d wip 2026-05-02 18:03:14 +01:00
6add4da543 wip 2026-05-02 18:02:20 +01:00
8ddcd3e853 wip 2026-05-02 18:02:13 +01:00
e3e178fd94 wip 2026-05-02 17:59:21 +01:00
7b1ec9f003 wip 2026-05-02 17:59:13 +01:00
1e48c5d473 wip 2026-05-02 17:59:00 +01:00
d00ec95dd9 wip 2026-05-02 17:58:55 +01:00
89f3d6cb39 wip 2026-05-02 17:45:54 +01:00
a07a2e512a wip 2026-05-02 17:39:15 +01:00
57eecb2b3d wip 2026-05-02 17:38:12 +01:00
9f804dc954 wip 2026-05-02 17:38:08 +01:00
7cbad4c589 wip 2026-05-02 17:38:01 +01:00
5cae93b059 wip 2026-05-02 17:37:56 +01:00
df829ad19d wip 2026-05-02 17:37:47 +01:00
635460653c wip 2026-05-02 17:37:34 +01:00
1aa80dc398 wip 2026-05-02 17:18:35 +01:00
8a14056111 wip 2026-05-02 17:15:46 +01:00
f247dd8e7b wip 2026-05-02 17:11:49 +01:00
1c7c9754fd wip 2026-05-02 17:07:08 +01:00
5f37939fda wip 2026-05-02 17:02:11 +01:00
28305fcb7a wip 2026-05-02 16:35:52 +01:00
5242803694 wip 2026-05-02 15:54:00 +01:00
925935a7e8 wip 2026-05-02 15:33:38 +01:00
460401c379 wip 2026-05-02 15:26:58 +01:00
348b439172 wip 2026-05-02 15:15:53 +01:00
a4e77766c5 wip 2026-05-02 15:07:51 +01:00
4a8c2d7d78 wip 2026-05-02 15:03:44 +01:00
7d51601120 wip 2026-05-02 14:15:37 +01:00
631bbe318b wip 2026-05-02 13:43:32 +01:00
9b7c15d5b6 wip 2026-05-02 12:03:35 +01:00
872796bd9b wip 2026-05-02 12:00:47 +01:00
c38d32ae86 wip 2026-05-02 09:49:33 +01:00
6407c4f78f wip 2026-05-02 01:58:39 +01:00
6e1bf63f6a wip 2026-05-02 01:40:35 +01:00
1d3e8bb5dd wip 2026-05-02 01:03:06 +01:00
3cc9b169e3 wip 2026-05-02 00:52:57 +01:00
c4911d046b wip 2026-05-02 00:06:04 +01:00
d0295f6168 wip 2026-05-01 23:59:25 +01:00
21efbc3107 wip 2026-05-01 22:55:38 +01:00
e59b99cd73 wip 2026-05-01 22:51:55 +01:00
bf48e20880 wip 2026-05-01 22:36:15 +01:00
53db68e005 wip 2026-05-01 22:31:00 +01:00
34f1e9a7f2 fix 2026-05-01 21:51:09 +01:00
57fd5ce042 wip 2026-05-01 21:50:56 +01:00
3bcabfb63b fix 2026-05-01 21:50:52 +01:00
aa3a7c303a wip 2026-05-01 21:32:59 +01:00
5c08a135f7 wip 2026-05-01 21:30:44 +01:00
6e0e030d71 wip 2026-05-01 21:22:33 +01:00
b2c07561e7 wip 2026-05-01 20:54:24 +01:00
97600b6f0b wip 2026-05-01 20:53:40 +01:00
8a51f15d65 wip 2026-05-01 20:45:51 +01:00
a349f53964 wip 2026-05-01 20:42:14 +01:00
a725e774c0 wip 2026-05-01 20:28:39 +01:00
bed5e5b54a wip 2026-05-01 20:02:46 +01:00
45b1d4ce20 wip 2026-05-01 19:50:54 +01:00
d106b70766 wip 2026-05-01 17:24:22 +01:00
6ac6c1518e wip 2026-05-01 17:17:33 +01:00
fda515a17f wip 2026-05-01 16:42:01 +01:00
0a97d3a288 asd 2026-05-01 15:33:06 +01:00
3802fa80e6 asd 2026-05-01 15:19:19 +01:00
5c3cbc14d2 wip 2026-05-01 15:07:10 +01:00
6b9c768172 wip 2026-05-01 14:39:23 +01:00
4a2fc179ae asd 2026-05-01 13:54:25 +01:00
b5bb2139dc wqdqwd 2026-05-01 12:57:12 +01:00
07416a3c61 wip 2026-05-01 03:30:28 +01:00
30b94e3034 qdw 2026-05-01 02:10:08 +01:00
be34eecb2f wip 2026-05-01 01:55:18 +01:00
5d9f95f8d4 qwdqwd 2026-05-01 01:44:08 +01:00
48b70a03d5 wip 2026-05-01 01:32:48 +01:00
4b8642b972 wip 2026-05-01 00:48:31 +01:00
8711d51eac fix 2026-05-01 00:19:44 +01:00
58dfb6da64 wip 2026-04-30 22:12:53 +01:00
d7735d1faa wip 2026-04-30 22:00:56 +01:00
6b59166f3c wip 2026-04-30 21:08:00 +01:00
3a26028d17 wip 2026-04-30 20:58:06 +01:00
3c91bf4ad2 wip 2026-04-30 20:51:57 +01:00
6a434be0f6 wip 2026-04-30 20:45:36 +01:00
f355b99bbf wip 2026-04-29 22:59:56 +01:00
95a00341e9 wip for now 2026-04-29 02:22:37 +01:00
fcf5c27914 chore: adds YouTube channel badge
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:56:04 -07:00
18bbca748f Merge branch '4.x' into 5.x 2026-04-18 07:03:46 -07:00
f142aad8ad Merge branch '4.x' into 5.x 2026-04-17 19:35:53 -07:00
74a28d4f5e fix: wrapper runner 2026-04-17 07:29:03 -07:00
6053e15d00 Merge branch '4.x' into 5.x 2026-04-17 06:07:14 -07:00
2d649d765f chore: adjusts tests 2026-04-11 01:54:13 +01:00
4fb4908570 Merge branch '4.x' into 5.x 2026-04-10 22:37:24 +01:00
e63a886f98 Merge pull request #1661 from Avnsh1111/fix/opposite-expectation-truncated-message
fix: preserve full error message in not() expectation failures
2026-04-10 11:48:24 +01:00
8dd650fd05 Merge branch '4.x' into 5.x 2026-04-09 21:39:15 +01:00
fbca346d7c fix: types 2026-04-07 14:40:55 +01:00
3f13bca0f7 just in case 2026-04-07 14:37:13 +01:00
d3acb1c56a fix: coverage 2026-04-07 14:33:41 +01:00
e601e6df31 fix: preserve full error message in not() expectation failures
When using not() expectations with custom error messages, the message
was truncated because throwExpectationFailedException() passed all
arguments through shortenedExport() which limits strings to ~40 chars.

Uses the full export() method for arguments instead of shortenedExport()
so custom error messages are displayed in their entirety.

Fixes #1533
2026-04-07 18:12:54 +05:30
6fdbca1226 fix: parallel testing 2026-04-06 23:37:49 +01:00
54359b895f Merge branch '4.x' into 5.x 2026-04-06 21:57:41 +01:00
44c04bfce1 chore: bumps paratest 2026-04-06 14:41:38 +01:00
271c680d3c Merge branch '4.x' into 5.x 2026-04-06 11:24:05 +01:00
4a1d8d27b8 chore: bumps dependencies 2026-04-03 12:12:27 +01:00
0f6924984c Merge branch '4.x' into 5.x 2026-04-03 12:02:36 +01:00
668ca9f5de feat: adds pao 2026-04-02 15:45:13 +01:00
f659a45311 Merge branch '4.x' into 5.x 2026-03-21 13:20:25 +00:00
12c1da29ee Merge branch '4.x' into 5.x 2026-03-10 21:21:24 +00:00
fa27c8daef chore: version 2026-02-17 17:52:40 +00:00
f0a08f0503 chore: missing types 2026-02-17 17:52:00 +00:00
2c040c5b1f chore: style 2026-02-17 17:45:50 +00:00
a9ce1fd739 chore: code refactor 2026-02-17 17:45:34 +00:00
3533356262 chore: updates snapshots 2026-02-17 17:44:56 +00:00
4aa41d0b14 chore: bumps dependencies 2026-02-17 17:41:38 +00:00
e4ed60085c chore: bumps dependencies 2026-02-17 17:18:45 +00:00
e2b119655d chore: point pestphp dependencies to ^5.0.0 2026-02-17 17:13:36 +00:00
fcf5baf0a9 chore: start preparing for pest 5.x 2026-02-17 16:55:03 +00:00
109 changed files with 4443 additions and 5546 deletions

13
.github/SECURITY.md vendored Normal file
View File

@ -0,0 +1,13 @@
# Security Policy
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
## Reporting a Vulnerability
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
All security vulnerabilities will be promptly addressed.

19
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
target-branch: "5.x"
groups:
github-actions:
patterns:
- "*"

View File

@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
with:
php-version: 8.3
php-version: 8.4
tools: composer:v2
coverage: none
extensions: sockets
@ -44,13 +44,13 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
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
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi

View File

@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
@ -51,7 +48,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
@ -76,21 +73,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

View File

@ -6,6 +6,7 @@
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
</p>
</p>

View File

@ -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();

View File

@ -1,55 +1,62 @@
#!/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)
const path = projectRequire.resolve('rolldown')
return await import(pathToFileURL(path).href)
}
const { createServer } = await loadVite()
async function readJsonWithComments(path) {
const raw = await readFile(path, 'utf8')
const stripped = raw
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
return JSON.parse(stripped)
}
async function loadAliasFromTsconfig() {
const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(PROJECT_ROOT, name)
if (!existsSync(p)) continue
let cfg
try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(PROJECT_ROOT, 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
}
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
@ -69,14 +76,44 @@ async function listPageFiles(pagesDir) {
return out
}
async function discoverPagesDir() {
const override = process.env.TIA_VITE_PAGES_DIR
if (override && override.length > 0) {
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
}
for (const rel of PAGE_DIR_CANDIDATES) {
const abs = resolve(PROJECT_ROOT, rel)
if (!existsSync(abs)) continue
const files = await listPageFiles(abs)
if (files.length > 0) return abs
}
return null
}
function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel)
return rel.slice(0, rel.length - ext.length)
}
function isLocalSpecifier(source, aliasKeys) {
if (source.startsWith('.') || source.startsWith('/')) return true
for (const key of aliasKeys) {
if (source === key || source.startsWith(key + '/')) return true
}
return false
}
async function main() {
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
const pagesDir = await discoverPagesDir()
if (pagesDir === null) {
process.stdout.write('{}')
return
}
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {
@ -84,82 +121,104 @@ 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 { rolldown } = await loadRolldown()
const alias = await loadAliasFromTsconfig()
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 },
})
// 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 {
for (const pagePath of pages) {
const pageComponent = pageComponentCache.get(pagePath)
const pageUrl = '/' + posix.relative(
PROJECT_ROOT.split(sep).join('/'),
pagePath.split(sep).join('/'),
)
const externalBare = {
name: 'pest-tia-external-bare',
resolveId(source) {
if (!source) return null
if (isLocalSpecifier(source, aliasKeys)) return null
return { id: source, external: true }
},
}
try {
await server.transformRequest(pageUrl, { ssr: false })
} catch {
// Transform errors (missing deps, syntax issues) shouldn't
// poison the whole graph — skip this page and continue.
continue
const assetStub = {
name: 'pest-tia-asset-stub',
load(id) {
if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
}
return null
},
}
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
if (!pageModule) continue
const input = Object.create(null)
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
// BFS over importedModules, scoped to files inside the project.
const visited = new Set()
const queue = [pageModule]
while (queue.length) {
const mod = queue.shift()
for (const imported of mod.importedModules) {
const id = imported.file ?? imported.id
if (!id || visited.has(id)) continue
visited.add(id)
const bundle = await rolldown({
input,
cwd: PROJECT_ROOT,
resolve: {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
},
transform: { jsx: 'preserve' },
treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
// Skip files outside the project root (node_modules, etc.)
// and virtual modules (`\0`-prefixed ids from plugins).
if (id.startsWith('\0')) continue
if (!id.startsWith(PROJECT_ROOT)) continue
try {
await bundle.generate({ format: 'esm' })
} finally {
await bundle.close()
}
const rel = relative(PROJECT_ROOT, id).split(sep).join('/')
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
const reverse = new Map()
const transitiveCache = new Map()
queue.push(imported)
const computeTransitive = (id, stack) => {
const cached = transitiveCache.get(id)
if (cached) return cached
if (stack.has(id)) return null
stack.add(id)
const acc = new Set()
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)) continue
const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r)
}
}
} finally {
clearTimeout(killer)
await server.close()
stack.delete(id)
transitiveCache.set(id, acc)
return acc
}
for (const page of pages) {
const pageComponent = componentNameFor(page, pagesDir)
const reachable = computeTransitive(page, new Set())
if (!reachable) continue
for (const rel of reachable) {
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
}
}
const payload = Object.create(null)
@ -172,8 +231,7 @@ async function main() {
}
try {
// Node 20 dynamic-import path — some environments are pickier than others.
void pathToFileURL // retained to silence tree-shakers referencing the import
void pathToFileURL
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))

View File

@ -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));

View File

@ -17,21 +17,20 @@
}
],
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"composer/xdebug-handler": "^3.0.5",
"php": "^8.4",
"brianium/paratest": "^7.22.4",
"nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.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.1.8",
"symfony/process": "^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.23",
"phpunit/phpunit": ">13.1.8",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@ -59,11 +58,12 @@
]
},
"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"
"mrpunyapal/peststan": "^0.2.10",
"laravel/pao": "^1.0.6",
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^5.0.0",
"psy/psysh": "^0.12.23"
},
"minimum-stability": "dev",
"prefer-stable": true,
@ -93,7 +93,6 @@
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v",
"test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [
"@test:lint",
@ -101,8 +100,7 @@
"@test:type:coverage",
"@test:unit",
"@test:parallel",
"@test:integration",
"@test:tia"
"@test:integration"
]
},
"extra": {

View File

@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
};
?>

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use PHPUnit\TextUI\Configuration\Builder;
/**
* @internal
*/
final class BootPhpUnitConfiguration implements Bootstrapper
{
public function boot(): void
{
(new Builder)->build(['pest']);
}
}

View File

@ -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,

View File

@ -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;
@ -84,10 +82,15 @@ trait Testable
public bool $__ran = false;
/**
* Set when a `BeforeEachable` plugin returns a cached success result.
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
* The active replay mode for this test, set in `setUp()` and checked
* in `__runTest()` / `tearDown()` to skip the body and after-each.
*/
private bool $__cachedPass = false;
private ReplayType $__replay = ReplayType::None;
/**
* The cached assertion count to replay, captured when entering replay mode.
*/
private int $__replayAssertions = 0;
/**
* The test's test closure.
@ -241,8 +244,6 @@ trait Testable
{
TestSuite::getInstance()->test = $this;
$this->__cachedPass = false;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description;
@ -275,72 +276,34 @@ trait Testable
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
// TIA replay short-circuit. Runs AFTER dataset/description/
// assignee metadata is populated so output and filtering still
// see the correct test name + tags on a cache hit, but BEFORE
// `parent::setUp()` and `beforeEach` so we skip the user's
// fixture setup (which is the whole point of replay — avoid
// paying for work whose outcome we already know).
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$replay = ReplayType::fromStatus($status);
if ($cached !== null) {
if ($cached->isSuccess()) {
$this->__cachedPass = true;
if ($replay !== ReplayType::None) {
assert($status !== null);
return;
}
match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
};
// Risky tests have no public PHPUnit hook to replay as-risky.
// Best available: short-circuit as a pass so the test doesn't
// misreport as a failure. Aggregate risky totals won't
// survive replay — accepted trade-off until PHPUnit grows a
// programmatic risky-marker API.
if ($cached->isRisky()) {
$this->__cachedPass = true;
return;
}
// Non-success: throw the matching PHPUnit exception. Runner
// catches it and marks the test with the correct status so
// skips, failures, incompletes and todos appear in output
// exactly as they did in the cached run.
if ($cached->isSkipped()) {
$this->markTestSkipped($cached->message());
}
if ($cached->isIncomplete()) {
$this->markTestIncomplete($cached->message());
}
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
return;
}
$recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder);
if ($recorder instanceof Recorder && $recorder->isActive()) {
if ($recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
? AutoloadEdges::snapshot()
: [];
parent::setUp();
// TIA blade-edge + table-edge recording (Laravel-only). Runs
// right after `parent::setUp()` so the Laravel app exists and
// the View / DB facades are bound; each arm call is
// idempotent against the current app instance so the 774-test
// suite doesn't stack 774 composers / listeners when Laravel
// keeps the same app across tests.
if ($recorder instanceof Recorder) {
BladeEdges::arm($recorder);
TableTracker::arm($recorder);
InertiaEdges::arm($recorder);
}
Collectors::armAll($recorder);
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
@ -349,18 +312,13 @@ trait Testable
}
$this->__callClosure($beforeEach, $arguments);
}
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) {
$recorder->linkSourcesForTest(
self::$__filename,
AutoloadEdges::newProjectFiles(
$autoloadBeforeSetUp,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
self::$__filename,
),
);
}
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__ran = true;
}
/**
@ -395,7 +353,7 @@ trait Testable
*/
protected function tearDown(...$arguments): void
{
if ($this->__cachedPass) {
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
@ -426,19 +384,12 @@ trait Testable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
if ($this->__cachedPass) {
// Feed the exact assertion count captured during the recorded
// run so Pest's "Tests: N passed (M assertions)" banner stays
// accurate on replay instead of collapsing to 1-per-test.
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->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;
}

View File

@ -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();
}
/**

View File

@ -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;
}

View File

@ -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));
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
'',
]);
}
public function exitCode(): int
{
return 0;
}
}

View File

@ -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;
}
}

View File

@ -238,7 +238,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 +915,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 +1130,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')),
);

View File

@ -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')),
);

View File

@ -166,7 +166,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
private static \$__filename = '$filename';
public static \$__filename = '$filename';
$methodsCode
}
@ -197,7 +197,7 @@ final class TestCaseFactory
if (
$method->closure instanceof \Closure &&
(new \ReflectionFunction($method->closure))->isStatic()
new \ReflectionFunction($method->closure)->isStatic()
) {
throw new TestClosureMustNotBeStatic($method);

View File

@ -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;
}

View File

@ -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.');
}
}

View File

@ -12,7 +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\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
@ -255,9 +257,11 @@ final readonly class Converter
$numberOfNotPassedTests = count(
array_unique(
array_map(
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) {
|| $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName();
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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);

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.6.3';
return '5.0.0-rc.9';
}
function testDirectory(string $file = ''): string

View File

@ -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));
}
/**

View File

@ -7,7 +7,6 @@ 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;
@ -25,11 +24,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;
@ -146,7 +151,6 @@ final class WrapperRunner implements RunnerInterface
public function run(): int
{
$directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory);
TestResultFacade::init();
EventFacade::instance()->seal();
@ -448,10 +452,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,

View File

@ -187,11 +187,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/
private function allTests(array $arguments): array
{
$output = (new Process([
$output = new Process([
'php',
...$this->removeParallelArguments($arguments),
'--list-tests',
]))->setTimeout(120)->mustRun()->getOutput();
])->setTimeout(120)->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
}

View File

@ -4,89 +4,72 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Composer\InstalledVersions;
use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* Pulls a team-shared TIA baseline on the first `--tia` run so new
* contributors and fresh CI workspaces start in replay mode instead of
* paying the ~30s record cost.
*
* Storage: **workflow artifacts**, not releases. A dedicated CI workflow
* (conventionally `.github/workflows/tia-baseline.yml`) runs the full
* suite under `--tia` and uploads the `.pest/tia/` directory as a named
* artifact (`pest-tia-baseline`) containing `graph.json` +
* `coverage.bin`. On dev
* machines, this class finds the latest successful run of that workflow
* and downloads the artifact via `gh`.
*
* Why artifacts, not releases:
* - No tag is created → no `push` event cascade into CI workflows.
* - No release event → no deploy workflows tied to `release:published`.
* - Retention is run-scoped and tunable (1-90 days) instead of clobbering
* a single floating tag.
* - Publishing is strictly CI-only: artifacts can't be produced from a
* developer's laptop. This enforces the "CI is the authoritative
* publisher" policy that local-publish paths would otherwise erode.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
* composer.lock, etc.) discards the pulled baseline and falls through to
* the regular record path.
*
* @internal
*/
final readonly class BaselineSync
{
/**
* Conventional workflow filename teams publish from. Not configurable
* for MVP — teams that outgrow the default can set
* `PEST_TIA_BASELINE_WORKFLOW` later.
*/
private const string WORKFLOW_FILE = 'tia-baseline.yml';
/**
* Artifact name the workflow uploads under. The artifact is a zip
* containing `graph.json` (always) + `coverage.bin` (optional).
*/
private const string ARTIFACT_NAME = 'pest-tia-baseline';
/**
* Asset filenames inside the artifact — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
/**
* Cooldown (in seconds) applied after a failed baseline fetch.
* Rationale: when the remote workflow hasn't published yet, every
* `pest --tia` invocation would otherwise re-hit `gh run list` and
* re-print the publish instructions — noisy + slow. Back off for a
* day, let the user override with `--refetch`.
*/
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
private const int FETCH_COOLDOWN_SECONDS = 86400;
private const array DIAGNOSES = [
'network' => [
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
],
'gh-auth' => [
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
'message' => 'authentication failed — run `gh auth login` and retry.',
],
'rate-limit' => [
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
'message' => 'GitHub API rate limit hit — try again later.',
],
'not-found' => [
'pattern' => '/404|not found|repository not found/i',
'message' => 'workflow or artifact not found in repo.',
],
'forbidden' => [
'pattern' => '/403|forbidden|access denied/i',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
],
];
public function __construct(
private State $state,
private OutputInterface $output,
) {}
/**
* Detects the repo, fetches the latest baseline artifact, writes its
* contents into the TIA state store. Returns true when the graph blob
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it.
*
* `$force = true` (driven by `--refetch`) ignores the post-failure
* cooldown so the user can retry on demand without waiting out the
* 24h window.
*/
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
private function renderBadge(string $type, string $content): void
{
View::render('components.badge', ['type' => $type, 'content' => $content]);
}
private function renderChild(string $text): void
{
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
@ -95,25 +78,23 @@ final readonly class BaselineSync
}
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
.'Override with <fg=cyan>--refetch</>.',
$this->renderBadge('WARN', sprintf(
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
$this->formatDuration($remaining),
));
return false;
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
$repo,
));
$payload = $this->download($repo);
$result = $this->download($repo, $projectRoot, $hasAnchor);
$payload = $result['payload'];
$failureKind = $result['failureKind'];
if ($payload === null) {
$this->startCooldown();
$this->emitPublishInstructions($repo);
if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown();
$this->emitPublishInstructions();
}
return false;
}
@ -126,23 +107,11 @@ final readonly class BaselineSync
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
// Successful fetch wipes any stale cooldown so the next failure
// (say, weeks later) starts a fresh 24h timer rather than inheriting
// one from the deep past.
$this->clearCooldown();
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
));
return true;
}
/**
* Seconds left on the cooldown, or `null` when the cooldown is cleared
* / expired / unreadable.
*/
private function cooldownRemaining(): ?int
{
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
@ -187,62 +156,18 @@ final readonly class BaselineSync
return $seconds.'s';
}
/**
* Prints actionable instructions for publishing a first baseline when
* the consumer-side fetch finds nothing.
*
* Behaviour splits on environment:
* - **CI:** a single line. The current run is almost certainly *the*
* publisher (it's what this workflow does by definition), so
* printing the whole recipe again is redundant and noisy.
* - **Local:** the full recipe, adapted to Laravel's pre-test steps
* (`.env.example` copy + `artisan key:generate`) when the framework
* is present. Generic PHP projects get a slimmer skeleton.
*/
private function emitPublishInstructions(string $repo): void
private function emitPublishInstructions(): void
{
if ($this->isCi()) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
);
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
return;
}
$yaml = $this->isLaravel()
? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml();
$preamble = [
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
'',
' To share the baseline with your team, add this workflow to the repo:',
'',
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
'',
];
$indentedYaml = array_map(
static fn (string $line): string => ' '.$line,
explode("\n", $yaml),
);
$trailer = [
'',
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
'',
];
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]);
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
}
/**
* True when running inside a CI provider. Conservative list — only the
* three providers Pest formally supports / sees in the wild. `CI=true`
* alone is ambiguous (users set it locally too) so we require a
* provider-specific flag.
*/
private function isCi(): bool
{
return getenv('GITHUB_ACTIONS') === 'true'
@ -250,91 +175,6 @@ final readonly class BaselineSync
|| getenv('CIRCLECI') === 'true';
}
private function isLaravel(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
/**
* Laravel projects need a populated `.env` and a generated `APP_KEY`
* before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException`
* fires during `setUp`. Include the standard pre-test dance plus the
* extension set typical Laravel apps rely on.
*/
private function laravelWorkflowYaml(): string
{
return <<<'YAML'
name: TIA Baseline
on:
push: { branches: [main] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: xdebug
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
- run: cp .env.example .env
- run: composer install --no-interaction --prefer-dist
- run: php artisan key:generate
- run: ./vendor/bin/pest --parallel --tia --coverage
- name: Stage baseline for upload
shell: bash
run: |
mkdir -p .pest-tia-baseline
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
- uses: actions/upload-artifact@v4
with:
name: pest-tia-baseline
path: .pest-tia-baseline/
retention-days: 30
YAML;
}
private function genericWorkflowYaml(): string
{
return <<<'YAML'
name: TIA Baseline
on:
push: { branches: [main] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', coverage: xdebug }
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pest --parallel --tia --coverage
- name: Stage baseline for upload
shell: bash
run: |
mkdir -p .pest-tia-baseline
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
- uses: actions/upload-artifact@v4
with:
name: pest-tia-baseline
path: .pest-tia-baseline/
retention-days: 30
YAML;
}
/**
* Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box.
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
* silently opts the repo out of auto-sync.
*/
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
@ -349,29 +189,20 @@ YAML;
return null;
}
// Find the `[remote "origin"]` section and the first `url` line
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
// SSH: git@github.com:org/repo(.git)
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
// Some teams configure this explicitly to pin the SSH port; the
// colon-separated form above doesn't match. Mirrors the parser
// in `Storage::originIdentity` so the same remote produces the
// same project key for both storage and remote-fetch.
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
}
@ -380,74 +211,319 @@ YAML;
}
/**
* Two-step fetch: find the latest successful run of the baseline
* workflow, then download the named artifact from it. Returns
* `['graph' => bytes, 'coverage' => bytes|null]` on success, or null
* if `gh` is unavailable, the workflow hasn't run yet, the artifact
* is missing, or any shell step fails.
*
* @return array{graph: string, coverage: ?string}|null
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo): ?array
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{
if (! $this->commandExists('gh')) {
return null;
}
$this->validateGhDependencies($hasAnchor);
$runId = $this->latestSuccessfulRunId($repo);
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return ['payload' => null, 'failureKind' => $listError['kind']];
}
if ($runId === null) {
return null;
return ['payload' => null, 'failureKind' => 'no-runs'];
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
return null;
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderChild(sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return ['payload' => null, 'failureKind' => null];
}
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
if (! $download['success']) {
return ['payload' => null, 'failureKind' => $download['failureKind']];
}
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
$this->trimDownloadCache($projectRoot);
return ['payload' => $payload, 'failureKind' => null];
}
/**
* @param array{kind: string, message: string} $diagnosis
*/
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
{
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
return;
}
Panic::with(new BaselineFetchFailed(
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
private function validateGhDependencies(bool $hasAnchor): void
{
if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.',
'Install it from https://cli.github.com.',
$hasAnchor,
));
}
if (! $this->ghAuthenticated()) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
$hasAnchor,
));
}
}
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId);
$this->output->writeln('');
$this->renderChild($artifactSize !== null
? sprintf(
'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
)
: sprintf(
'Downloading TIA baseline from %s…',
$repo,
));
$process = new Process([
'gh', 'run', 'download', $runId,
'-R', $repo,
'-n', self::ARTIFACT_NAME,
'-D', $tmpDir,
'-D', $runCacheDir,
]);
$process->setTimeout(120.0);
$process->setTimeout(900.0);
$process->start();
$startedAt = microtime(true);
$tick = 0;
while ($process->isRunning()) {
$this->renderDownloadProgress($startedAt, $tick++);
usleep(120_000);
}
$process->wait();
$this->clearProgressLine();
if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Baseline download failed — %s',
$diagnosis['message'],
));
return ['success' => false, 'failureKind' => $diagnosis['kind']];
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
*/
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
{
$payload = $this->readArtifact($runCacheDir);
if ($payload === null) {
$this->cleanup($runCacheDir);
Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).',
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
$hasAnchor,
));
}
return $payload;
}
private function artifactSize(string $repo, string $runId): ?int
{
$process = new Process([
'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
'--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME,
),
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
$this->cleanup($tmpDir);
return null;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$size = trim($process->getOutput());
return is_numeric($size) ? (int) $size : null;
}
private function renderDownloadProgress(float $startedAt, int $tick): void
{
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
$elapsed = max(0.0, microtime(true) - $startedAt);
$frame = $frames[$tick % count($frames)];
$this->output->write(sprintf(
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
$frame,
$elapsed,
));
}
private function clearProgressLine(): void
{
$this->output->write("\r\033[K");
}
private function dirSize(string $dir): int
{
if (! is_dir($dir)) {
return 0;
}
$total = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isFile()) {
$total += $entry->getSize();
}
}
return $total;
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/
private function readArtifact(string $dir): ?array
{
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
if ($graph === false) {
$this->cleanup($tmpDir);
return null;
}
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
$this->cleanup($tmpDir);
return [
'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage,
'sizeOnDisk' => $this->dirSize($dir),
];
}
private function downloadCacheDir(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
}
private function safeRunId(string $runId): string
{
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
return $sanitised === '' ? 'unknown' : $sanitised;
}
private function trimDownloadCache(string $projectRoot): void
{
$root = $this->downloadCacheDir($projectRoot);
if (! is_dir($root)) {
return;
}
$entries = @scandir($root);
if ($entries === false) {
return;
}
$candidates = [];
foreach ($entries as $entry) {
if (in_array($entry, ['.', '..'], true)) {
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) {
continue;
}
$mtime = @filemtime($path);
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
}
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
return;
}
usort(
$candidates,
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
);
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
$this->cleanup($stale['path']);
}
}
/**
* Queries GitHub for the most recent successful run of the baseline
* workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found"
* into an empty string, which we map to null.
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/
private function latestSuccessfulRunId(string $repo): ?string
private function latestSuccessfulRunIdWithError(string $repo): array
{
$process = new Process([
'gh', 'run', 'list',
@ -462,27 +538,49 @@ YAML;
$process->run();
if (! $process->isSuccessful()) {
return null;
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
}
$runId = trim($process->getOutput());
return $runId === '' ? null : $runId;
return [$runId === '' ? null : $runId, null];
}
private function ghAuthenticated(): bool
{
$process = new Process(['gh', 'auth', 'status']);
$process->setTimeout(10.0);
$process->run();
return $process->isSuccessful();
}
/**
* @return array{kind: string, message: string}
*/
private function classifyGhError(string $output): array
{
$output = trim($output);
if ($output === '') {
return ['kind' => 'unknown', 'message' => 'unknown error'];
}
foreach (self::DIAGNOSES as $kind => $diagnosis) {
if (preg_match($diagnosis['pattern'], $output) === 1) {
return ['kind' => $kind, 'message' => $diagnosis['message']];
}
}
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
}
private function commandExists(string $cmd): bool
{
$probe = new Process(['command', '-v', $cmd]);
$probe->run();
$process = new Process(['which', $cmd]);
$process->run();
if ($probe->isSuccessful()) {
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
return $process->isSuccessful();
}
private function cleanup(string $dir): void
@ -491,13 +589,17 @@ YAML;
return;
}
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
if ($entries !== false) {
foreach ($entries as $entry) {
if (is_file($entry)) {
@unlink($entry);
}
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isDir()) {
@rmdir($entry->getPathname());
} else {
@unlink($entry->getPathname());
}
}

View File

@ -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);
}
});
}
}

View File

@ -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));
}
}

View File

@ -4,22 +4,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process;
/**
* Detects files that changed between the last recorded TIA run and the
* current working tree.
*
* Strategy:
* 1. If we have a `recordedAtSha`, `git diff <sha>..HEAD` captures committed
* changes on top of the recording point.
* 2. `git status --short` captures unstaged + staged + untracked changes on
* top of that.
*
* We return relative paths to the project root. Deletions are included so the
* caller can decide whether to invalidate: a deleted source file may still
* appear in the graph and should mark its dependents as affected.
*
* @internal
*/
final readonly class ChangedFiles
@ -27,33 +15,16 @@ final readonly class ChangedFiles
public function __construct(private string $projectRoot) {}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push) — in
* that case the graph should be rebuilt.
*/
/**
* Removes files whose current content hash matches the snapshot from the
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
* that git still reports as modified but whose content is bit-identical
* to the previous TIA invocation.
*
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
*/
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{
if ($lastRunTree === []) {
return $files;
}
// Union: `$files` (what git currently reports) + every path that was
// dirty last run. The second set matters for reverts — when a user
// undoes a local edit, the file matches HEAD again and git reports
// it clean, so it would never enter `$files`. But it has genuinely
// changed vs the snapshot we captured during the bad run, so it
// must be checked.
$candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) {
@ -64,72 +35,30 @@ final readonly class ChangedFiles
foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null;
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
$exists = is_file($absolute);
$current = $this->currentHash($file);
if ($snapshot === null) {
// File wasn't in last-run tree at all — trust git's signal.
if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file;
continue;
}
if (! $exists) {
// Missing on disk. We always invalidate here, even when
// the snapshot also recorded "deleted" (sentinel '').
// The `snapshot=='' && !exists` shortcut would in
// principle say "no change since last run, cached
// result is still valid" — but it's only safe if the
// cached result was recorded *during* a run that saw
// the file as deleted. A previous run that captured
// the deletion in `lastRunTree` but failed to refresh
// the cached pass/fail (paratest worker race, an
// earlier plugin bug, etc.) would leave the cache
// stuck on a stale pass from before the deletion.
// Skipping invalidation in that state perpetuates the
// wrong result on every subsequent run. Treat any
// missing file as a change; cost is one re-run per
// `--tia` while the file stays deleted.
$remaining[] = $file;
continue;
}
$hash = ContentHash::of($absolute);
if ($hash === false) {
$remaining[] = $file;
continue;
}
if ($hash === $snapshot) {
// Same state as the last TIA invocation — cached
// result is still valid, no need to re-run.
continue;
}
// Differs from the snapshot. This includes the
// revert-back-to-baseline case (last run had a real edit
// and was cached against that edit; this run reverted).
// Even though the file now matches what's at the recorded
// SHA, the cached test result reflects the *modified*
// version, not the baseline version — so it's stale and
// the test must re-run to refresh the cache. An earlier
// version of this filter short-circuited on
// matches-baseline, which served the stale failure
// forever after the user reverted.
$remaining[] = $file;
}
return $remaining;
}
private function currentHash(string $relativePath): ?string
{
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! is_file($absolute)) {
return null;
}
$hash = ContentHash::of($absolute);
return $hash === false ? null : $hash;
}
/**
* Computes content hashes for the given project-relative files. Used to
* snapshot the working tree after a successful run so the next run can
* detect which files are actually different.
*
* @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash
*/
@ -141,9 +70,6 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
// Record the deletion with an empty-string sentinel so the
// next run recognises "still deleted" as unchanged rather
// than re-flagging the file as a fresh change.
$out[$file] = '';
continue;
@ -161,15 +87,9 @@ final readonly class ChangedFiles
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push).
*/
public function since(?string $sha): ?array
{
if (! $this->gitAvailable()) {
return null;
}
$files = [];
if ($sha !== null && $sha !== '') {
@ -182,30 +102,17 @@ final readonly class ChangedFiles
$files = array_merge($files, $this->workingTreeChanges());
// Normalise + dedupe, filtering out paths that can never belong to the
// graph: vendor (caught by the fingerprint instead), cache dirs, and
// anything starting with a dot we don't care about.
$unique = [];
foreach ($files as $file) {
if ($file === '') {
continue;
}
if ($this->shouldIgnore($file)) {
continue;
}
$unique[$file] = true;
}
$candidates = array_keys($unique);
$candidates = array_keys($this->filterIgnored($unique));
// Behavioural de-noising: for every file git calls "changed", hash
// the current content and the content at `$sha` through
// `ContentHash::of()`. A change that only touched comments /
// whitespace / blade `{{-- --}}` blocks produces the same hash on
// both sides and gets dropped before it can invalidate any test.
// Without this, a single-comment edit on a migration re-runs the
// entire DB-touching suite.
if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha);
}
@ -222,18 +129,9 @@ final readonly class ChangedFiles
$remaining = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
$currentHash = $this->currentHash($file);
if (! is_file($absolute)) {
// Deleted on disk — a genuine change, keep it.
$remaining[] = $file;
continue;
}
$currentHash = ContentHash::of($absolute);
if ($currentHash === false) {
if ($currentHash === null) {
$remaining[] = $file;
continue;
@ -242,16 +140,12 @@ final readonly class ChangedFiles
$baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) {
// Couldn't read the baseline (new file, binary, `git show`
// failed). Err on the side of re-running.
$remaining[] = $file;
continue;
}
$baselineHash = ContentHash::ofContent($file, $baselineContent);
if ($currentHash !== $baselineHash) {
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
$remaining[] = $file;
}
}
@ -259,12 +153,6 @@ final readonly class ChangedFiles
return $remaining;
}
/**
* Reads `$path` at `$sha` via `git show`. Returns null when the file
* didn't exist at that SHA, when git errors, or when the content
* isn't valid UTF-8-safe bytes (rare — binary files that happen to
* be tracked).
*/
private function contentAtSha(string $sha, string $path): ?string
{
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
@ -278,41 +166,52 @@ final readonly class ChangedFiles
return $process->getOutput();
}
private function shouldIgnore(string $path): bool
/**
* @param array<string, true> $candidates
* @return array<string, true>
*/
private function filterIgnored(array $candidates): array
{
static $prefixes = [
'.pest/',
'.phpunit.cache/',
'.phpunit.result.cache',
'vendor/',
'node_modules/',
// Laravel regenerates these from manifest state
// (package.json, service providers) at boot — they're
// fully derived, not authored. Treating them as
// "changes" just flaps the diff noisily.
'bootstrap/cache/',
];
if ($candidates === []) {
return $candidates;
}
foreach ($prefixes as $prefix) {
if (str_starts_with($path, (string) $prefix)) {
return true;
$process = new Process(
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot,
);
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode();
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return $candidates;
}
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
if ($ignored !== '') {
unset($candidates[$ignored]);
}
}
return false;
return $candidates;
}
public function currentBranch(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
throw new MissingDependency('Tia mode', 'git');
}
$branch = trim($process->getOutput());
@ -320,14 +219,6 @@ final readonly class ChangedFiles
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
public function gitAvailable(): bool
{
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
$process->run();
return $process->isSuccessful();
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
@ -336,9 +227,6 @@ final readonly class ChangedFiles
);
$process->run();
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
// "unreachable" and force a rebuild.
return $process->getExitCode() === 0;
}
@ -354,7 +242,7 @@ final readonly class ChangedFiles
$process->run();
if (! $process->isSuccessful()) {
return [];
throw new MissingDependency('Tia mode', 'git');
}
return $this->splitLines($process->getOutput());
@ -365,14 +253,6 @@ final readonly class ChangedFiles
*/
private function workingTreeChanges(): array
{
// `-z` produces NUL-terminated records with no path quoting, so paths
// that contain spaces, tabs, unicode or other special characters
// are passed through verbatim. Without `-z`, git wraps such paths in
// quotes with backslash escapes, which would corrupt our lookup keys.
//
// Record format: `XY <SP> <path> <NUL>` for most entries, and
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
// fields).
$process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
@ -380,7 +260,7 @@ final readonly class ChangedFiles
$process->run();
if (! $process->isSuccessful()) {
return [];
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
@ -403,8 +283,6 @@ final readonly class ChangedFiles
$status = substr($record, 0, 2);
$path = substr($record, 3);
// Renames/copies emit two records: the new path first, then the
// original. Consume both.
if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path;
@ -424,15 +302,11 @@ final readonly class ChangedFiles
public function currentSha(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
throw new MissingDependency('Tia mode', 'git');
}
$sha = trim($process->getOutput());

View File

@ -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);
}
}
}

View File

@ -7,27 +7,60 @@ namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/**
* User-facing TIA configuration, returned by `pest()->tia()`.
*
* Usage in `tests/Pest.php`:
*
* pest()->tia()->watch([
* 'resources/js/**\/*.tsx' => 'tests/Browser',
* 'public/build/**\/*' => 'tests/Browser',
* ]);
*
* Patterns are merged with the built-in defaults (config, routes, views,
* frontend assets, migrations). Duplicate glob keys overwrite the default
* mapping so users can redirect a pattern to a narrower directory.
*
* @internal
*/
final class Configuration
{
/**
* Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults.
*
* @return $this
*/
public function always(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
return $this;
}
/**
* @return $this
*/
public function locally(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
return $this;
}
/**
* @return $this
*/
public function filtered(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markFiltered();
return $this;
}
/**
* @return $this
*/
public function baselined(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markBaselined();
return $this;
}
/**
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this
*/

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -11,33 +11,6 @@ use SebastianBergmann\CodeCoverage\CodeCoverage;
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 +19,7 @@ final class CoverageMerger
{
$state = self::state();
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
@ -55,46 +28,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 +140,12 @@ final class CoverageMerger
return array_keys($ids);
}
private static function state(): ?State
private static function state(): State
{
try {
$state = Container::getInstance()->get(State::class);
} catch (Throwable) {
return null;
}
$state = Container::getInstance()->get(State::class);
assert($state instanceof State);
return $state instanceof State ? $state : null;
return $state;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage

View File

@ -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);
}
});
}
}

View File

@ -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;
}
}

View File

@ -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,
};
}
}

View File

@ -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);
@ -108,33 +95,26 @@ final readonly class FileState implements State
return $keys;
}
/**
* Absolute path for `$key`. Not part of the interface — used by the
* coverage merger and similar callers that need direct filesystem
* access (e.g. `require` on a cached PHP file). Consumers that only
* deal in bytes should go through `read()` / `write()`.
*/
public function pathFor(string $key): string
{
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
}
/**
* Returns the resolved root if it exists already, otherwise `null`.
* Used by read-side helpers so they don't eagerly create the directory
* just to find nothing inside.
*/
private function resolvedRoot(): ?string
{
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir);
return $resolved === false ? null : $resolved;
if ($resolved === false) {
return null;
}
return $this->resolvedRoot = $resolved;
}
/**
* Creates the root dir on demand. Returns false only when creation
* fails and the directory still isn't there afterwards.
*/
private function ensureRoot(): bool
{
if (is_dir($this->rootDir)) {

View File

@ -4,87 +4,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Finder\Finder;
/**
* Captures environmental inputs that, when changed, may make the TIA graph
* or its recorded results stale. The fingerprint is split into two buckets:
*
* - **structural** — describes what the graph's *edges* were recorded
* against. If any of these drift (`composer.lock`, `composer.json`,
* `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the
* edges themselves are potentially wrong and the graph must rebuild
* from scratch. `tests/TestCase.php` and `tests/Pest.php` are
* intentionally NOT here — those are handled by per-test ancestor
* linking (`Recorder::linkAncestorFiles`) and the Php watch pattern
* respectively, which give precise invalidation rather than a wholesale
* rebuild.
* - **environmental** — describes the *runtime* the results were captured
* on (PHP minor, extension set). Drift here means the edges are still
* trustworthy, but the cached per-test results (pass/fail/time) may
* not reproduce on this machine. Tia's handler drops the branch's
* results + coverage cache and re-runs to freshen them, rather than
* re-recording from scratch. Pest's own version is intentionally NOT
* here — `composer.lock`'s structural hash already moves whenever the
* installed Pest version changes.
*
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
* rebuilt on first load; the schema bump in the structural bucket takes
* care of that automatically.
*
* @internal
*/
final readonly class Fingerprint
{
// Bump this whenever the set of inputs or the hash algorithm changes,
// so older graphs are invalidated automatically.
//
// v5: ChangedFiles now hashes via `ContentHash` (normalises PHP
// tokens + Blade whitespace/comments) instead of raw bytes.
// Old graphs' run-tree hashes are incompatible and must be
// rebuilt.
// v6: Graph gained per-test table edges (`$testTables`) powering
// surgical migration invalidation. Worker partial shape
// changed to `{files, tables}`. Old graphs have no table
// coverage, which would leave every DB test invalidated by
// any migration change — force a rebuild so the new edges
// are populated.
// v7: Graph gained per-test Inertia page-component edges
// (`$testInertiaComponents`) for surgical page-file
// invalidation. Worker partial now includes an `inertia`
// section. Old graphs have no component edges; without a
// rebuild Vue/React page edits would fall through to the
// broad watch pattern even when precise matching could have
// worked.
// v8: Graph gained `$jsFileToComponents` — reverse dependency
// map computed at record time from Vite's module graph (or
// the PHP fallback) so shared components / layouts /
// composables invalidate the specific pages they're used
// by, not every browser test.
// v9: `ContentHash` now normalises JS/TS/Vue/Svelte comments +
// whitespace. Old graphs' run-tree hashes for those files
// were raw-byte; mixing formats would flag every JS file as
// changed on first run.
// v10: `vite.config.*` hashed into the structural bucket. A
// Vite config change reshapes the module dependency graph
// that `JsModuleGraph` records; without a graph rebuild
// the stored `$jsFileToComponents` map silently goes stale.
// v11: `composer.json` added (autoload-dev / extra discovery
// changes). `tests/TestCase.php` and `tests/Pest.php` are
// intentionally NOT fingerprinted — they're handled by the
// watch pattern + `Recorder::linkAncestorFiles` reflection
// walk, which gives precise per-test invalidation rather
// than a wholesale rebuild that trashes the entire graph.
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
// now hash via `ContentHash::of()` so cosmetic comment +
// whitespace edits don't fire rebuilds. composer.json and
// composer.lock hash a behavioural subset — description,
// keywords, scripts, authors, install timestamps, dist
// URLs etc. no longer drift the structural fingerprint.
private const int SCHEMA_VERSION = 12;
private const int SCHEMA_VERSION = 17;
/**
* @return array{
* structural: array<string, int|string|null>,
* environmental: array<string, string|null>,
* environmental: array<string, int|string|null>,
* }
*/
public static function compute(string $projectRoot): array
@ -92,50 +24,27 @@ final readonly class Fingerprint
return [
'structural' => [
'schema' => self::SCHEMA_VERSION,
// `composer.lock` hashed against a *behavioural*
// subset (per-package version + reference + autoload +
// extra). Skips per-package install timestamps, dist
// URLs, support links, descriptions — none of which
// affect what code runs.
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
// Pest's generated classes bake the code-generation logic
// in — if TestCaseFactory changes (new attribute, different
// method signature, etc.) every previously-recorded edge is
// stale. Hashing via `ContentHash::of()` so cosmetic edits
// (comments, formatting) don't drift the fingerprint.
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
// `vite.config.*` reshapes the module graph
// `JsModuleGraph` records at the next `--tia` run; if
// the config drifts without a rebuild, the stored
// `$jsFileToComponents` map is silently stale.
// `viteConfigHash` itself uses `ContentHash::of()` so
// a comment-only edit to vite.config doesn't rebuild.
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'vite_config' => self::viteConfigHash($projectRoot),
// `composer.json` hashed against a behavioural subset:
// autoload(-dev), require(-dev), extra (Laravel
// package discovery), repositories, minimum-stability,
// and the platform / allow-plugins entries from
// `config`. Cosmetic fields (description, keywords,
// scripts, authors, funding, support) are excluded.
'composer_json' => self::composerJsonHash($projectRoot),
// 'package_json' => self::packageJsonHash($projectRoot),
'package_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot),
// 'composer_json' => self::composerJsonHash($projectRoot),
],
'environmental' => [
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
// almost never matches a dev's Herd/Homebrew install, and
// the patch rarely changes anything test-visible.
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint($projectRoot),
'php_minor' => PHP_MAJOR_VERSION,
// 'extensions' => self::extensionsFingerprint($projectRoot),
// 'env_files' => self::envFilesHash($projectRoot),
],
];
}
/**
* True when the structural buckets match. Drift here means the edges
* are potentially wrong; caller should discard the graph and rebuild.
*
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
@ -151,68 +60,54 @@ final readonly class Fingerprint
}
/**
* Returns the list of structural field names that drifted between
* the stored and current fingerprints. Empty list = no drift.
* Caller uses this to tell the user *why* the graph rebuilt — a
* generic "graph outdated" message leaves people staring at
* unrelated diffs.
*
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function structuralDrift(array $stored, array $current): array
{
$a = self::structuralOnly($stored);
$b = self::structuralOnly($current);
$drifts = [];
foreach ($a as $key => $value) {
if ($key === 'schema') {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === 'schema') {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
return self::detectDrift(
self::structuralOnly($stored),
self::structuralOnly($current),
'schema',
);
}
/**
* Returns a list of field names that drifted between the stored and
* current environmental fingerprints. Empty list = no drift. Caller
* uses this to print a human-readable warning and to decide whether
* per-test results should be dropped (any drift → yes).
*
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function environmentalDrift(array $stored, array $current): array
{
$a = self::environmentalOnly($stored);
$b = self::environmentalOnly($current);
return self::detectDrift(
self::environmentalOnly($stored),
self::environmentalOnly($current),
);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
* @return list<string>
*/
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
{
$drifts = [];
foreach ($a as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
@ -240,11 +135,6 @@ final readonly class Fingerprint
}
/**
* Returns `$fingerprint[$key]` as an `array<string, mixed>` if it exists
* and is an array, otherwise empty. Legacy flat-shape fingerprints
* (schema ≤ 3) return empty here, which makes `structuralMatches` fail
* and the caller rebuild — the clean migration path.
*
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
@ -267,18 +157,15 @@ final readonly class Fingerprint
return $normalised;
}
/**
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
* at the project root. Most projects have exactly one; we accept
* any of the five recognised extensions without assuming which
* the user picked. Returns null when no config file exists —
* treated as "no Vite project" by the matcher, no drift.
*/
private static function viteConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
@ -289,171 +176,86 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
/**
* Behavioural subset of `composer.json`. Keeps the keys that
* actually move test outcomes (autoload, require, extra,
* repositories, minimum-stability, platform / allow-plugins
* config) and drops cosmetic ones (description, keywords,
* scripts, authors, funding, homepage, support). Falls back to
* a raw hash on parse errors so any change still rebuilds.
*/
private static function composerJsonHash(string $projectRoot): ?string
private static function jsConfigHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.json';
$parts = [];
if (! is_file($path)) {
return null;
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
$relevantConfig = array_intersect_key($config, [
'platform' => true,
'allow-plugins' => true,
]);
$relevant = [
'autoload' => $data['autoload'] ?? null,
'autoload-dev' => $data['autoload-dev'] ?? null,
'require' => $data['require'] ?? null,
'require-dev' => $data['require-dev'] ?? null,
'extra' => $data['extra'] ?? null,
'repositories' => $data['repositories'] ?? null,
'minimum-stability' => $data['minimum-stability'] ?? null,
'prefer-stable' => $data['prefer-stable'] ?? null,
'config' => $relevantConfig === [] ? null : $relevantConfig,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
/**
* Behavioural subset of `composer.lock`. For every package in
* `packages` and `packages-dev`, keeps version + dist/source
* reference (commit SHA — catches dev-branch updates that don't
* bump the version string) + autoload(-dev) + extra (Laravel
* package discovery). Drops install timestamps, dist URLs,
* support links, descriptions, etc. — none of which change what
* code runs.
*/
private static function composerLockHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.lock';
return self::trackedHash($projectRoot, 'composer.lock');
}
if (! is_file($path)) {
private static function packageLockHash(string $projectRoot): ?string
{
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::trackedHash($projectRoot, $name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function trackedHash(string $projectRoot, string $relativePath): ?string
{
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;
}
$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;
}
/**
* Recursively sorts associative arrays by key so semantically
* equivalent JSON produces the same hash regardless of key
* ordering. Lists (numeric arrays) keep their order — they're
* meaningful in `repositories`, `autoload.files`, etc.
*/
private static function sortRecursively(mixed &$value): void
{
if (! is_array($value)) {
return;
if (! is_file($projectRoot.'/'.$relativePath)) {
return false;
}
$isAssoc = ! array_is_list($value);
static $cache = [];
if ($isAssoc) {
ksort($value);
$key = $projectRoot."\0".$relativePath;
if (isset($cache[$key])) {
return $cache[$key];
}
foreach ($value as &$child) {
self::sortRecursively($child);
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
return $cache[$key] = true;
}
$finder = (new Finder)
->in($projectRoot)
->depth('== 0')
->name($relativePath)
->ignoreVCSIgnored(true);
return $cache[$key] = $finder->hasResults();
}
private static function contentHashOrNull(string $path): ?string
@ -477,82 +279,4 @@ final readonly class Fingerprint
return $hash === false ? null : $hash;
}
/**
* Deterministic hash of the extensions the project actually depends on —
* the `ext-*` entries in composer.json's `require` / `require-dev`. An
* incidental extension loaded on the developer's machine (or on CI) but
* not declared as a dependency can't affect correctness of the test
* suite, so we ignore it here to keep the drift signal quiet.
*
* Declared extensions that aren't currently loaded record as `missing`,
* which is itself a drift signal worth surfacing.
*/
private static function extensionsFingerprint(string $projectRoot): string
{
$extensions = self::declaredExtensions($projectRoot);
if ($extensions === []) {
return hash('xxh128', '');
}
sort($extensions);
$parts = [];
foreach ($extensions as $name) {
$version = phpversion($name);
$parts[] = $name.'@'.($version === false ? 'missing' : $version);
}
return hash('xxh128', implode("\n", $parts));
}
/**
* Extension names (without the `ext-` prefix) that appear as keys under
* `require` or `require-dev` in the project's composer.json. Returns
* an empty list when composer.json is missing / unreadable / malformed,
* so the environmental fingerprint stays stable in those cases rather
* than flapping.
*
* @return list<string>
*/
private static function declaredExtensions(string $projectRoot): array
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
if ($raw === false) {
return [];
}
$data = json_decode($raw, true);
if (! is_array($data)) {
return [];
}
$extensions = [];
foreach (['require', 'require-dev'] as $section) {
$packages = $data[$section] ?? null;
if (! is_array($packages)) {
continue;
}
foreach (array_keys($packages) as $package) {
if (is_string($package) && str_starts_with($package, 'ext-')) {
$extensions[] = substr($package, 4);
}
}
}
return array_values(array_unique($extensions));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -8,85 +8,112 @@ use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* Builds a reverse dependency map for the project's JS sources under
* `resources/js/**` — for every source file, the list of Inertia page
* components that transitively import it.
*
* Tries two resolvers in order:
*
* 1. **Node helper** (`bin/pest-tia-vite-deps.mjs`). Spins up a
* headless Vite server in middleware mode, walks Vite's own
* module graph for each page entry, and outputs JSON. Uses the
* project's real `vite.config.*`, so aliases, plugins, and SFC
* transformers produce the same graph Vite itself would use.
*
* 2. **PHP fallback** (`JsImportParser`). Regex-scans ES imports
* and resolves `@/` / `~/` aliases manually. Strictly less
* precise — anything it can't resolve is skipped, leaving the
* caller to fall back to the broad watch pattern. Only kicks in
* when the Node helper is unusable (no Node on PATH, no Vite
* installed, vite.config fails to load).
*
* Callers invoke this at record time; results are persisted into the
* graph so replay never re-runs the resolver. On stale-map detection
* the callers decide whether to rebuild.
*
* @internal
*/
final class JsModuleGraph
{
private const int NODE_TIMEOUT_SECONDS = 25;
private const int NODE_TIMEOUT_SECONDS = 180;
private const string CACHE_FILE = 'js-module-graph.cache.json';
/**
* @return array<string, list<string>> project-relative source path → sorted list of page component names
* @var list<string>
*/
public const array VITE_CONFIG_NAMES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
];
/**
* Candidate page directories, in priority order. Must stay in sync with
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
*
* @var list<string>
*/
private const array PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
];
/**
* @var list<string>
*/
private const array PAGE_EXTENSIONS = [
'vue', 'svelte',
'tsx', 'jsx',
'ts', 'js',
'mts', 'cts', 'mjs', 'cjs',
];
/**
* @return array<string, list<string>>
*/
public static function build(string $projectRoot): array
{
$viaNode = self::tryNodeHelper($projectRoot);
$result = self::resolve($projectRoot);
if ($viaNode !== null) {
return $viaNode;
}
return JsImportParser::parse($projectRoot);
return $result ?? [];
}
/**
* Strict variant — only runs the Node helper, never falls back to
* the PHP parser. Returns null when Node isn't available or Vite
* won't load.
*
* Used at replay time when we need to *trust a negative result*
* (i.e., "no page imports this file, so it's orphan, safe to
* skip"). The PHP fallback is conservative on positives but can
* miss imports that rely on custom aliases or plugins — negative
* results from it cannot be trusted for orphan pruning.
*
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
{
return self::tryNodeHelper($projectRoot);
return self::resolve($projectRoot);
}
/**
* True when the project looks like a Vite + Node project we can
* ask for a module graph. Gate for callers that want to skip the
* resolver entirely on non-Vite apps.
*/
public static function isApplicable(string $projectRoot): bool
{
if (! self::hasViteConfig($projectRoot)) {
return false;
}
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
// starter kit (`pages/`) conventions are accepted — projects
// running on a case-sensitive filesystem (Linux CI) get
// exactly one of the two, and we shouldn't refuse to walk the
// graph based on which one it picks.
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return self::firstExistingPagesDir($projectRoot) !== null;
}
private static function firstExistingPagesDir(string $projectRoot): ?string
{
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_dir($abs)) {
continue;
}
if (self::dirHasPageFile($abs)) {
return $abs;
}
}
return null;
}
private static function dirHasPageFile(string $dir): bool
{
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
} catch (\UnexpectedValueException) {
return false;
}
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
return true;
}
}
@ -97,7 +124,40 @@ final class JsModuleGraph
/**
* @return array<string, list<string>>|null
*/
private static function tryNodeHelper(string $projectRoot): ?array
private static function resolve(string $projectRoot): ?array
{
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) {
return $cached;
}
}
$process = self::buildNodeProcess($projectRoot);
if (! $process instanceof Process) {
return null;
}
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
private static function buildNodeProcess(string $projectRoot): ?Process
{
if (! self::hasViteConfig($projectRoot)) {
return null;
@ -119,30 +179,19 @@ final class JsModuleGraph
return null;
}
// Tell the Node helper which casing this project uses for its
// pages directory. The helper defaults to `resources/js/Pages`;
// the Laravel React starter ships lowercase `resources/js/pages`,
// and on a case-sensitive filesystem the helper would otherwise
// walk a non-existent directory and emit an empty module graph.
$env = [];
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$env['TIA_VITE_PAGES_DIR'] = $candidate;
break;
}
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process;
}
/**
* @return array<string, list<string>>|null
*/
private static function parseNodeOutput(string $output): ?array
{
/** @var mixed $decoded */
$decoded = json_decode($process->getOutput(), true);
$decoded = json_decode($output, true);
if (! is_array($decoded)) {
return null;
@ -176,14 +225,167 @@ final class JsModuleGraph
return $out;
}
private static function hasViteConfig(string $projectRoot): bool
private static function fingerprint(string $projectRoot): ?string
{
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
$parts = [];
foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) {
continue;
}
$stat = @stat($path);
$bytes = @file_get_contents($path);
$parts[] = 'config:'.$name
.':'.($stat === false ? '0' : (string) $stat['mtime'])
.':'.($stat === false ? '0' : (string) $stat['size'])
.':'.($bytes === false ? '' : hash('sha256', $bytes));
}
return false;
if ($parts === []) {
return null;
}
$override = getenv('TIA_VITE_PAGES_DIR');
if (is_string($override) && $override !== '') {
$parts[] = 'pagesDirOverride:'.$override;
}
$pagesDir = self::firstExistingPagesDir($projectRoot);
if ($pagesDir !== null) {
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
}
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
}
sort($entries);
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
}
return hash('sha256', implode('|', $parts));
}
/**
* @return array<string, list<string>>|null
*/
private static function readCache(string $projectRoot, string $fingerprint): ?array
{
$path = self::cachePath($projectRoot);
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
/** @var mixed $decoded */
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
return null;
}
$graph = $decoded['graph'] ?? null;
if (! is_array($graph)) {
return null;
}
$out = [];
foreach ($graph as $key => $value) {
if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue;
}
$names = [];
foreach ($value as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}
$out[$key] = $names;
}
return $out;
}
/**
* @param array<string, list<string>> $graph
*/
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{
$path = self::cachePath($projectRoot);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$payload = json_encode([
'fingerprint' => $fingerprint,
'graph' => $graph,
]);
if ($payload === false) {
return;
}
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $payload) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
}
private static function cachePath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function hasViteConfig(string $projectRoot): bool
{
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
}
}

View File

@ -8,121 +8,30 @@ use Pest\TestSuite;
use ReflectionClass;
/**
* Captures per-test file coverage using the PCOV driver.
*
* Acts as a singleton because PCOV has a single global collection state and
* the recorder is wired into PHPUnit through two distinct subscribers
* (`Prepared` / `Finished`) that must share context.
*
* @internal
*/
final class Recorder
{
/**
* Test file currently being recorded, or `null` when idle.
*/
private ?string $currentTestFile = null;
/**
* Aggregated map: absolute test file → set<absolute source file>.
*
* @var array<string, array<string, true>>
*/
/** @var array<string, array<string, true>> */
private array $perTestFiles = [];
/**
* Aggregated map: absolute test file → set<lowercase table name>.
* Populated by `TableTracker` from `DB::listen` callbacks; consumed
* at record finalize to populate the graph's `$testTables` edges
* that drive migration-change impact analysis.
*
* @var array<string, array<string, true>>
*/
/** @var array<string, array<string, true>> */
private array $perTestTables = [];
/**
* Aggregated map: absolute test file → set<Inertia component name>.
* Populated by `InertiaEdges` from Inertia responses observed at
* request-handled time; consumed at record finalize to populate
* the graph's per-test component edges that drive Vue / React
* page-file impact analysis.
*
* @var array<string, array<string, true>>
*/
/** @var array<string, array<string, true>> */
private array $perTestInertiaComponents = [];
/**
* Set of absolute test files whose class hierarchy uses one of
* Laravel's database-resetting traits (`RefreshDatabase`,
* `DatabaseMigrations`, `DatabaseTransactions`). Captured at
* `beginTest` so the finalize path can augment their table edges
* even when seeders / pre-test DML fired before `TableTracker`
* armed.
*
* @var array<string, true>
*/
/** @var array<string, true> */
private array $perTestUsesDatabase = [];
/**
* Cached class → test file resolution.
*
* @var array<string, string|null>
*/
/** @var array<string, string|null> */
private array $classFileCache = [];
/**
* Cached class → "uses Laravel DB trait" introspection result.
*
* @var array<string, bool>
*/
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
/**
* Reverse map of project-local source file → list of class /
* interface / trait names declared in it. Built incrementally as
* tests run and new classes get autoloaded; consumed by
* `linkSourceDependencies()` so a test's covered file's
* declared classes can be walked for their interfaces, traits,
* and parents (which the coverage driver doesn't capture
* because interface declarations and empty traits emit no
* executable bytecode).
*
* @var array<string, list<string>>
*/
private array $fileToClassNames = [];
/**
* Names already folded into `$fileToClassNames`. Lets the
* incremental refresher skip classes seen in a previous test.
*
* @var array<string, true>
*/
private array $indexedClassNames = [];
/**
* Cached "files this class transitively depends on (interfaces,
* traits, parent chain, parents' interfaces and traits)" for
* project-local class names. Avoids re-walking the same
* hierarchy on every test that touches the same class.
*
* @var array<string, list<string>>
*/
private array $classDependencyCache = [];
/**
* Cached test-file import resolution.
*
* @var array<string, list<string>>
*/
private array $testImportFileCache = [];
/**
* Included-file snapshot captured at the start of the current test.
*
* @var array<string, true>
*/
private array $includedFilesAtTestStart = [];
private bool $active = false;
private bool $driverChecked = false;
@ -131,6 +40,8 @@ final class Recorder
private string $driver = 'none';
private ?SourceScope $sourceScope = null;
public function activate(): void
{
$this->active = true;
@ -148,15 +59,6 @@ final class Recorder
$this->driver = 'pcov';
$this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Xdebug 3+ exposes the active mode set via `xdebug_info`,
// so we can ask directly instead of probing with a
// start/stop pair. The probe approach used to emit
// E_WARNING when coverage mode was off; with monitoring
// agents (Sentry, Bugsnag) hooked into the error
// handler stack that warning could be reported as a
// real error. `xdebug_info('mode')` is silent and
// returns the active modes as a list, so a presence
// check is enough.
$modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) {
@ -171,13 +73,6 @@ final class Recorder
return $this->driverAvailable;
}
public function driver(): string
{
$this->driverAvailable();
return $this->driver;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active || ! $this->driverAvailable()) {
@ -195,26 +90,11 @@ final class Recorder
}
$this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
// Walk the parent-class chain and link each ancestor's defining
// file as a source dependency of this test. Captures the common
// `tests/TestCase.php` case (where the user's base may be
// trait-only and have no executable lines for the coverage
// driver to pick up), and any deeper hierarchy. Vendor parents
// are skipped — those are pinned by `composer.lock` and don't
// need per-test edges. Same idea applies to traits used by the
// ancestors: a trait's body executes when the test method
// calls into it, so coverage already captures it; we only need
// the explicit walk for ancestors whose own bodies might be
// empty.
$this->linkAncestorFiles($className);
$this->linkImportedFiles($file);
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
@ -222,7 +102,6 @@ final class Recorder
return;
}
// Xdebug
\xdebug_start_code_coverage();
}
@ -234,53 +113,35 @@ final class Recorder
if ($this->driver === 'pcov') {
\pcov\stop();
$scope = $this->sourceScope();
$filesToCollectCoverageFor = [];
foreach (\pcov\waiting() as $file) {
if (is_string($file) && $scope->contains($file)) {
$filesToCollectCoverageFor[] = $file;
}
}
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive);
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = $this->filesWithExecutedLines($data);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
// `true` resets Xdebug's internal buffer so the next `start()`
// does not accumulate earlier tests' coverage into the current
// one — otherwise the graph becomes progressively polluted.
\xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data);
}
foreach (array_keys($data) as $sourceFile) {
foreach ($coveredFiles as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
foreach (AutoloadEdges::newProjectFiles(
$this->includedFilesAtTestStart,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
$this->currentTestFile,
) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// Walk each covered class's interfaces / traits / parent chain
// and link those files explicitly. Interface declarations have
// no executable bytecode, so coverage drivers never emit lines
// for them — without this walk, a signature change to an
// interface like `Viewable` would leave the cached results of
// every test that exercises an implementing class stale,
// because the interface file never enters the graph through
// the coverage path.
$this->linkSourceDependencies(array_keys($data));
$this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
}
/**
* Records an extra source-file dependency for the currently-running
* test. Used by collaborators that capture edges the coverage driver
* cannot see — Blade templates rendered through Laravel's view
* factory are the motivating case (their `.blade.php` source never
* executes directly; a cached compiled PHP file does). No-op when
* the recorder is inactive or no test is in flight, so callers can
* fire it unconditionally from app-level hooks.
*/
public function linkSource(string $sourceFile): void
{
if (! $this->active) {
@ -298,369 +159,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
/**
* Records source dependencies for a specific test file. Used for edges
* captured before `Prepared` has opened the normal per-test recorder window.
*
* @param iterable<int, string> $sourceFiles
*/
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
return;
}
if ($testFile === '') {
return;
}
foreach ($sourceFiles as $sourceFile) {
if ($sourceFile === '') {
continue;
}
$this->perTestFiles[$testFile][$sourceFile] = true;
}
}
/**
* For each project-local source file the coverage driver
* captured for this test, finds the classes / interfaces / traits
* declared in it and links every file in their declarative
* hierarchy: implemented interfaces (transitive), used traits,
* and parent classes (with their own interfaces and traits).
*
* Coverage drivers only record executable lines, so an interface
* signature change (e.g. adding a return type to a `Viewable`
* method) never registers — the interface file has no bytecode
* to instrument. Without this walk, every class implementing the
* interface would silently keep its stale cached result through
* the change, even though `--parallel` (no TIA) catches the
* incompatibility immediately.
*
* @param array<int, string> $coveredFiles absolute paths from coverage
*/
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
return;
}
$this->refreshClassMap();
foreach ($coveredFiles as $coveredFile) {
if (! isset($this->fileToClassNames[$coveredFile])) {
continue;
}
foreach ($this->fileToClassNames[$coveredFile] as $name) {
foreach ($this->classDependencies($name) as $depFile) {
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
}
}
}
}
/**
* Incrementally folds every project-local class / interface /
* trait declared since the last refresh into `$fileToClassNames`.
* PHP only ever appends to its declared-symbol lists (classes
* never get unloaded), so iterating from `$indexedClassNames`'s
* cardinality forward is sufficient — and over a long suite this
* is dominated by the first test, since most classes are loaded
* by then.
*/
private function refreshClassMap(): void
{
$names = array_merge(
get_declared_classes(),
get_declared_interfaces(),
get_declared_traits(),
);
foreach ($names as $name) {
if (isset($this->indexedClassNames[$name])) {
continue;
}
$this->indexedClassNames[$name] = true;
// Names came directly from `get_declared_*`, so the
// class/interface/trait is guaranteed loaded — but
// `class_exists($name, false)` (no autoload) keeps the
// string narrowed to `class-string` for static analysis
// and the `ReflectionClass` constructor stays in its
// documented happy path.
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
continue;
}
$reflection = new ReflectionClass($name);
if ($reflection->isInternal()) {
continue;
}
$file = $reflection->getFileName();
if (! is_string($file)) {
continue;
}
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
continue;
}
$this->fileToClassNames[$file][] = $name;
}
}
/**
* Returns the project-local files the named class declaratively
* depends on: implemented interfaces (transitive), used traits,
* and the entire parent chain (each with their own interfaces
* and traits). Cached per class because the answer is invariant
* across a single process.
*
* @return list<string>
*/
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
return $this->classDependencyCache[$className];
}
if (! class_exists($className, false)
&& ! interface_exists($className, false)
&& ! trait_exists($className, false)) {
return $this->classDependencyCache[$className] = [];
}
$reflection = new ReflectionClass($className);
$files = [];
$linkSymbol = static function (string $name) use (&$files): void {
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
return;
}
$r = new ReflectionClass($name);
if ($r->isInternal()) {
return;
}
$f = $r->getFileName();
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
return;
}
$files[$f] = true;
};
// `getInterfaceNames()` is transitive — it returns interfaces
// from parent classes and parent interfaces too — so a single
// pass covers the whole interface graph.
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
// Direct + ancestor traits. `getTraitNames()` doesn't recurse
// into traits-using-traits, but that's a rare pattern in
// application code; if a project genuinely needs it, the
// coverage driver will pick up the executed bytecode of the
// outer trait and the dependency walk runs against the
// resulting class anyway.
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $reflection->getParentClass();
while ($parent !== false && ! $parent->isInternal()) {
$f = $parent->getFileName();
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$f] = true;
}
foreach ($parent->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $parent->getParentClass();
}
return $this->classDependencyCache[$className] = array_keys($files);
}
/**
* Records every project-local ancestor class's defining file as a
* source dependency of the currently-running test. PCOV / Xdebug
* record *executable lines* — a base class whose body is just
* `class TestCase extends BaseTestCase { use CreatesApplication; }`
* has no executable bytecode of its own, so the driver doesn't
* emit a line for it and it never enters the graph through the
* usual coverage path. This walk fills that gap by asking
* reflection for each parent's file and linking it explicitly.
*/
private function linkAncestorFiles(string $className): void
{
if (! class_exists($className, false)) {
return;
}
$reflection = new ReflectionClass($className);
$parent = $reflection->getParentClass();
while ($parent !== false) {
if ($parent->isInternal()) {
break;
}
$file = $parent->getFileName();
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
}
$parent = $parent->getParentClass();
}
}
/**
* Links project-local classes imported by the test file. This catches
* declaration-only support classes / enums / interfaces that may never emit
* executable coverage lines, and avoids relying on global autoload timing.
*/
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
return;
}
foreach ($this->importedFilesFor($testFile) as $file) {
$this->perTestFiles[$this->currentTestFile][$file] = true;
}
}
/**
* @return list<string>
*/
private function importedFilesFor(string $testFile): array
{
if (array_key_exists($testFile, $this->testImportFileCache)) {
return $this->testImportFileCache[$testFile];
}
$source = @file_get_contents($testFile);
if ($source === false) {
return $this->testImportFileCache[$testFile] = [];
}
$files = [];
foreach ($this->importedClassNames($source) as $className) {
$file = $this->findAutoloadFile($className);
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$file] = true;
}
}
return $this->testImportFileCache[$testFile] = array_keys($files);
}
/**
* @return list<string>
*/
private function importedClassNames(string $source): array
{
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
$classes = [];
foreach ($matches[1] as $import) {
$import = trim($import);
if ($import === '') {
continue;
}
$open = strpos($import, '{');
$close = strrpos($import, '}');
if ($open !== false && $close !== false && $close > $open) {
$prefix = trim(trim(substr($import, 0, $open)), '\\');
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
foreach ($items as $item) {
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
if ($class !== null) {
$classes[$class] = true;
}
}
continue;
}
$class = $this->normaliseImportedClass($import);
if ($class !== null) {
$classes[$class] = true;
}
}
return array_keys($classes);
}
private function normaliseImportedClass(string $import): ?string
{
$import = trim(trim($import), '\\');
if ($import === '') {
return null;
}
$parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false || $parts === []) {
return null;
}
$class = trim(trim($parts[0]), '\\');
return $class === '' ? null : $class;
}
private function findAutoloadFile(string $className): ?string
{
foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) {
continue;
}
if (! method_exists($loader[0], 'findFile')) {
continue;
}
/** @var mixed $file */
$file = $loader[0]->findFile($className);
if (is_string($file) && $file !== '') {
$real = @realpath($file);
return $real === false ? $file : $real;
}
}
return null;
}
/**
* True when `$className` (or any of its ancestors) uses one of
* Laravel's database-resetting traits. Walking up `getTraits()` is
* necessary because Pest test classes are eval'd from the
* generated `*.php` test file and the trait usually lives on a
* shared `tests/TestCase.php` ancestor. Result is cached per class
* — class hierarchies don't change within a process.
*/
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
@ -692,14 +190,6 @@ final class Recorder
return $this->classUsesDatabaseCache[$className] = false;
}
/**
* Records that the currently-running test queried `$table`. Called
* by `TableTracker` for every DML statement Laravel's `DB::listen`
* reports; the table name has already been extracted by
* `TableExtractor::fromSql()` so we just store it. No-op outside
* a test window, so the callback is safe to leave armed across
* setUp / tearDown boundaries.
*/
public function linkTable(string $table): void
{
if (! $this->active) {
@ -717,15 +207,6 @@ final class Recorder
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
}
/**
* Records that the currently-running test server-side-rendered the
* named Inertia component. The name is whatever
* `Inertia::render($component, …)` was called with — typically a
* slash-separated path like `Users/Show` that maps to
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
* so the underlying listener can stay armed without leaking
* state between tests.
*/
public function linkInertiaComponent(string $component): void
{
if (! $this->active) {
@ -743,9 +224,7 @@ final class Recorder
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
}
/**
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
*/
/** @return array<string, array<int, string>> */
public function perTestFiles(): array
{
$out = [];
@ -757,9 +236,7 @@ final class Recorder
return $out;
}
/**
* @return array<string, array<int, string>> absolute test file → sorted list of table names.
*/
/** @return array<string, array<int, string>> */
public function perTestTables(): array
{
$out = [];
@ -773,9 +250,7 @@ final class Recorder
return $out;
}
/**
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
*/
/** @return array<string, array<int, string>> */
public function perTestInertiaComponents(): array
{
$out = [];
@ -789,9 +264,7 @@ final class Recorder
return $out;
}
/**
* @return array<string, true> absolute test file → true for tests using a Laravel DB-resetting trait.
*/
/** @return array<string, true> */
public function perTestUsesDatabase(): array
{
return $this->perTestUsesDatabase;
@ -817,47 +290,56 @@ final class Recorder
return null;
}
/**
* Resolves the file that *defines* the test class.
*
* Order of preference:
* 1. Pest's generated `$__filename` static — the original `*.php` file
* containing the `test()` calls (the eval'd class itself has no file).
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
* is intentionally more specific than `ReflectionMethod::getFileName()`
* (which would return the *trait* file for methods brought in via
* `uses SharedTestBehavior`).
*/
private function readPestFilename(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
$reflection = new ReflectionClass($className);
assert(property_exists($className, '__filename') && is_string($className::$__filename));
if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
return $className::$__filename;
}
/**
* Clears all captured state. Useful for long-running hosts (daemons,
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
* — without this, coverage from run N would bleed into run N+1.
* @param array<string, mixed> $data
* @return list<string>
*/
private function filesWithExecutedLines(array $data): array
{
$out = [];
foreach ($data as $file => $lines) {
if (! is_array($lines)) {
continue;
}
$covered = [];
foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) {
$covered[] = $line;
}
}
if ($covered === []) {
continue;
}
$lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue;
}
$out[] = $file;
}
return $out;
}
private function sourceScope(): SourceScope
{
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
}
public function reset(): void
{
$this->currentTestFile = null;
@ -867,9 +349,7 @@ final class Recorder
$this->perTestUsesDatabase = [];
$this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->fileToClassNames = [];
$this->indexedClassNames = [];
$this->classDependencyCache = [];
$this->sourceScope = null;
$this->active = false;
}
}

View File

@ -4,27 +4,28 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* Collects per-test status + message during the run so the graph can persist
* them for faithful replay. PHPUnit's own result cache discards messages
* during serialisation — this collector retains them.
*
* @internal
*/
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?string $currentTestFile = null;
private ?float $startTime = null;
public function testPrepared(string $testId): void
public function testPrepared(string $testId, ?string $testFile = null): void
{
$this->currentTestId = $testId;
$this->currentTestFile = $testFile;
$this->startTime = microtime(true);
}
@ -34,7 +35,7 @@ final class ResultCollector
return;
}
$this->record(0, '');
$this->record(TestStatus::success());
}
public function testFailed(string $message): void
@ -43,7 +44,7 @@ final class ResultCollector
return;
}
$this->record(7, $message);
$this->record(TestStatus::failure($message));
}
public function testErrored(string $message): void
@ -52,7 +53,7 @@ final class ResultCollector
return;
}
$this->record(8, $message);
$this->record(TestStatus::error($message));
}
public function testSkipped(string $message): void
@ -61,7 +62,7 @@ final class ResultCollector
return;
}
$this->record(1, $message);
$this->record(TestStatus::skipped($message));
}
public function testIncomplete(string $message): void
@ -70,7 +71,7 @@ final class ResultCollector
return;
}
$this->record(2, $message);
$this->record(TestStatus::incomplete($message));
}
public function testRisky(string $message): void
@ -79,11 +80,11 @@ final class ResultCollector
return;
}
$this->record(5, $message);
$this->record(TestStatus::risky($message));
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
public function all(): array
{
@ -98,11 +99,7 @@ final class ResultCollector
}
/**
* Injects externally-collected results (e.g. partials flushed by parallel
* workers) into this collector so the parent can persist them in the same
* snapshot pass as non-parallel runs.
*
* @param array<string, array{status: int, message: string, time: float, assertions: int}> $results
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/
public function merge(array $results): void
{
@ -115,21 +112,18 @@ final class ResultCollector
{
$this->results = [];
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
/**
* Called by the Finished subscriber after a test's outcome + assertion
* events have all fired. Clears the "currently recording" pointer so
* the next test's events don't get mis-attributed.
*/
public function finishTest(): void
{
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
private function record(int $status, string $message): void
private function record(TestStatus $status): void
{
if ($this->currentTestId === null) {
return;
@ -139,17 +133,17 @@ final class ResultCollector
? round(microtime(true) - $this->startTime, 3)
: 0.0;
// PHPUnit can fire more than one outcome event per test — the
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
// Last-wins semantics preserve the most specific status; the
// existing assertion count (if any) survives the overwrite.
$existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [
'status' => $status,
'message' => $message,
'status' => $status->asInt(),
'message' => $status->message(),
'time' => $time,
'assertions' => $existing['assertions'] ?? 0,
];
if ($this->currentTestFile !== null) {
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
}
}
}

View File

@ -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);
}
}

View File

@ -5,36 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Resolves TIA's on-disk state directory.
*
* Default location: `$HOME/.pest/tia/<project-key>/`. Keeping state in the
* user's home directory (rather than under `vendor/pestphp/pest/`) means:
*
* - `composer install` / path-repo reinstalls don't wipe the graph.
* - The state lives outside the project tree, so there is nothing for
* users to gitignore or accidentally commit.
* - Multiple worktrees of the same repo share one cache naturally.
*
* The project key is derived from the git origin URL when available — a
* CI workflow running on `github.com/org/repo` and a developer's clone
* of the same remote both compute the *same* key, which is what lets the
* CI-uploaded baseline line up with the dev-side reader. When the project
* is not in git, the key falls back to a hash of the absolute path so
* unrelated projects on the same machine stay isolated.
*
* When no home directory is resolvable (`HOME` / `USERPROFILE` both
* unset — the tests-tia sandboxes strip these deliberately, and some
* locked-down CI environments do the same), state falls back to
* `<projectRoot>/.pest/tia/`. That path is project-local but still
* survives composer installs, so the degradation is graceful.
*
* @internal
*/
final class Storage
{
/**
* Directory where TIA's State blobs live for `$projectRoot`.
*/
public static function tempDir(string $projectRoot): string
{
$home = self::homeDir();
@ -51,11 +25,46 @@ final class Storage
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
}
/**
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
* Windows. Returns null if neither resolves to an existing
* directory, in which case callers fall back to project-local state.
*/
public static function purge(string $projectRoot): void
{
$dir = self::tempDir($projectRoot);
if (! is_dir($dir)) {
return;
}
self::removeRecursive($dir);
}
private static function removeRecursive(string $dir): void
{
$entries = @scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) {
self::removeRecursive($path);
continue;
}
@unlink($path);
}
@rmdir($dir);
}
private static function homeDir(): ?string
{
foreach (['HOME', 'USERPROFILE'] as $key) {
@ -70,27 +79,7 @@ final class Storage
}
/**
* Folder name for `$projectRoot` under `~/.pest/tia/`.
*
* Strategy — each step rules out a class of collision:
*
* 1. If the project has a git origin URL, use a **normalised** form
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
* and `https://github.com/foo/bar` all collapse to
* `github.com/foo/bar` — three developers cloning the same repo
* by different transports share one cache, which is what we want.
* 2. Otherwise, use the canonicalised absolute path (`realpath`).
* Two unrelated `app/` checkouts under different parent folders
* have different realpaths → different hashes → isolated.
* 3. Hash the chosen input with sha256 and keep the first 16 hex
* chars — 64 bits of entropy makes accidental collision
* astronomically unlikely even across thousands of projects.
* 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/`
* is readable; the slug is cosmetic only, all isolation comes
* from the hash.
*
* Result: `myapp-a1b2c3d4e5f67890`.
*/
private static function projectKey(string $projectRoot): string
{
@ -105,12 +94,6 @@ final class Storage
return $slug === '' ? $hash : $slug.'-'.$hash;
}
/**
* Canonical git origin identity for `$projectRoot`, or null when
* no origin URL can be parsed. The returned form is
* `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git
* protocol clones of the same remote produce the same value.
*/
private static function originIdentity(string $projectRoot): ?string
{
$url = self::rawOriginUrl($projectRoot);
@ -129,8 +112,6 @@ final class Storage
return strtolower($m[1].'/'.$m[2]);
}
// Unrecognised form — hash the raw URL so different inputs still
// diverge, but lowercased so the only variance is intentional.
return strtolower($url);
}
@ -155,11 +136,6 @@ final class Storage
return null;
}
/**
* Filesystem-safe kebab of `$name`. Cosmetic only — used as a
* human-readable prefix on the hash so `~/.pest/tia/` lists
* recognisable folders.
*/
private static function slug(string $name): string
{
$slug = strtolower($name);

View File

@ -5,46 +5,14 @@ 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
{
/**
* 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.
*/
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
/**
* @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
{
@ -55,23 +23,12 @@ final class TableExtractor
}
$prefix = strtolower(substr($trimmed, 0, 6));
$matched = false;
foreach (self::DML_PREFIXES as $dml) {
if (str_starts_with($prefix, $dml)) {
$matched = true;
break;
}
}
$matched = array_any(self::DML_PREFIXES, fn (string $dml): bool => str_starts_with($prefix, $dml));
if (! $matched) {
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 (preg_match_all($pattern, $sql, $matches) === false) {
@ -106,35 +63,11 @@ 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) {
@ -148,10 +81,6 @@ final class TableExtractor
}
}
// 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';
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
@ -163,14 +92,6 @@ final class TableExtractor
}
}
// 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',
@ -196,11 +117,6 @@ final class TableExtractor
return $out;
}
/**
* 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.
*/
private static function isSchemaMeta(string $name): bool
{
$lower = strtolower($name);

View File

@ -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);
}
}

View File

@ -0,0 +1,155 @@
<?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;
}
$suffix = $directory->suffix();
if ($suffix !== '') {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$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[] = str_starts_with($suffix, '.') ? $suffix : '.'.$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)), '/');
}
}

View File

@ -7,23 +7,16 @@ namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* Watch patterns for frontend assets that affect browser tests.
*
* Uses `BrowserTestIdentifier` from pest-plugin-browser to auto-discover tests
* using `visit()`. Also keeps the `tests/Browser` convention when present.
*
* @internal
*/
final readonly class Browser implements WatchDefault
{
public function applicable(): bool
{
// Browser tests can exist in any PHP project. We only activate when
// there is an actual `tests/Browser` directory OR pest-plugin-browser
// is installed.
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
}
@ -33,18 +26,10 @@ final readonly class Browser implements WatchDefault
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [
'resources/js/**/*.js',
'resources/js/**/*.ts',
'resources/js/**/*.tsx',
'resources/js/**/*.jsx',
'resources/js/**/*.vue',
'resources/js/**/*.svelte',
'resources/css/**/*.css',
'resources/css/**/*.scss',
'resources/css/**/*.less',
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js',
'public/build/**/*.css',
'resources/js/** !*.php',
'resources/css/** !*.php',
'public/hot !*.php',
'public/** !*.php',
];
$patterns = [];
@ -69,9 +54,6 @@ final readonly class Browser implements WatchDefault
$targets[] = $candidate;
}
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find exact tests using `visit()` outside the
// conventional Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;

View File

@ -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],
];
}
}

View File

@ -5,16 +5,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* Watch patterns for Laravel projects.
*
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
* `Prepared` event where TIA's coverage window opens). That means PHP files
* loaded during boot — config, routes, service providers, migrations — are
* invisible to the coverage driver. Watch patterns are the only way to
* track them.
*
* @internal
*/
final readonly class Laravel implements WatchDefault
@ -28,54 +21,21 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
return [
// Config — loaded during app boot (setUp), invisible to coverage.
// Affects both Feature and Unit: Pest.php commonly binds fakes
// and seeds DB based on config values.
'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath],
// Routes — loaded during boot. HTTP/Feature tests depend on them.
'routes/*.php' => [$testPath],
'routes/**/*.php' => [$testPath],
// Service providers / bootstrap — loaded during boot, affect
// bindings, middleware, event listeners, scheduled tasks.
'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath],
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
// setUp. Schema changes can break any test that touches DB.
'database/migrations/**/*.php' => [$testPath],
// Seeders — often run globally via Pest.php beforeEach.
'database/seeders/**/*.php' => [$testPath],
'storage/fixtures/**/*' => [$testPath],
// Factories — loaded lazily but still PHP that coverage may miss
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath],
'app/** !*.php' => [$testPath],
// Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$testPath],
// Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$testPath],
'resources/views/**' => [$testPath],
// Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot).
'lang/**/*.php' => [$testPath],
'lang/**/*.json' => [$testPath],
'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$testPath],
'lang/**' => [$testPath],
'resources/lang/**' => [$testPath],
// Build tool config — affects compiled assets consumed by
// browser and Inertia tests.
'vite.config.js' => [$testPath],
'vite.config.ts' => [$testPath],
'webpack.mix.js' => [$testPath],
'tailwind.config.js' => [$testPath],
'tailwind.config.ts' => [$testPath],
'postcss.config.js' => [$testPath],
'vite.config.* !*.php' => [$testPath],
'webpack.mix.* !*.php' => [$testPath],
'tailwind.config.* !*.php' => [$testPath],
'postcss.config.* !*.php' => [$testPath],
];
}
}

View File

@ -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],
];

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* Baseline watch patterns for any PHP project.
*
* @internal
*/
final readonly class Php implements WatchDefault
@ -18,55 +18,20 @@ final readonly class Php implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
// NOTE: composer.json / composer.lock changes are caught by the
// fingerprint (which hashes composer.lock). PHP files are tracked by
// the coverage driver. Only non-PHP, non-fingerprinted files that
// can silently alter test behaviour belong here.
return [
// Environment files — can change DB drivers, feature flags,
// queue connections, etc. Not PHP, not fingerprinted. Covers
// the local-override variants (`.env.local`, `.env.testing.local`)
// that both Laravel and Symfony recommend for machine-specific
// config.
'.env' => [$testPath],
'.env.testing' => [$testPath],
'.env.local' => [$testPath],
'.env.*.local' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
// phpunit.xml.dist and other XML overrides are not individually
// tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath],
'phpunit.xml*' => [$testPath],
// `tests/Pest.php` is loaded once per suite (during BootFiles)
// so its `pest()->extend()`, `expect()->extend()`, helpers,
// etc. execute outside the per-test coverage window — no
// edge captures it. Watch-pattern broadcast triggers a
// replay of every test (results refresh) without a full
// record-mode graph rebuild.
$testPath.'/Pest.php' => [$testPath],
$testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath],
// Pest dataset definitions are loaded once at boot, outside
// the per-test coverage window — no edge captures them. A
// change to a shared dataset can flip the result of any test
// that uses it, so broadcast every dataset edit to the full
// suite.
$testPath.'/Datasets/**/*.php' => [$testPath],
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
// assertions. A fixture change can flip a test result.
$testPath.'/Fixtures/**/*.json' => [$testPath],
$testPath.'/Fixtures/**/*.csv' => [$testPath],
$testPath.'/Fixtures/**/*.xml' => [$testPath],
$testPath.'/Fixtures/**/*.txt' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
];
}

View File

@ -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],
];
}
}

View File

@ -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;
}

View File

@ -4,29 +4,15 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* Maps non-PHP file globs to the tests they should invalidate.
*
* Coverage drivers only see `.php` files. Frontend assets, config files,
* Blade templates, routes and environment files are invisible to the graph.
* Watch patterns bridge the gap: when a changed file matches a glob, every
* test under the associated directory (or the exact associated test file) is
* marked as affected.
*
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
* each implementation probes the current project and contributes patterns
* when applicable. Users extend via `pest()->tia()->watch(…)`.
*
* @internal
*/
final class WatchPatterns
{
/**
* All known default providers, in evaluation order.
*
* @var array<int, class-string<WatchDefault>>
*/
private const array DEFAULTS = [
@ -38,17 +24,26 @@ final class WatchPatterns
WatchDefaults\Browser::class,
];
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
/**
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
*/
private array $patterns = [];
/**
* Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
* calls are already in `$this->patterns`).
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
*/
private array $parsed = [];
private bool $enabled = false;
private bool $locally = false;
private bool $filtered = false;
private bool $baselined = false;
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
@ -60,33 +55,27 @@ final class WatchPatterns
continue;
}
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], $dirs),
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], $dirs),
));
}
}
}
/**
* Adds user-defined patterns. Merges with existing entries so a single
* glob can map to multiple directories.
*
* @param array<string, string> $patterns glob → project-relative test dir/file
* @param array<string, string> $patterns pattern key → project-relative test dir/file
*/
public function add(array $patterns): void
{
foreach ($patterns as $glob => $dir) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], [$dir]),
foreach ($patterns as $key => $dir) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], [$dir]),
));
}
}
/**
* Returns all test targets whose watch patterns match at least one of
* the given changed files.
*
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files.
@ -100,11 +89,13 @@ final class WatchPatterns
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $glob => $dirs) {
if ($this->globMatches($glob, $file)) {
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
foreach ($this->patterns as $key => $dirs) {
if (! $this->keyMatches($key, $file)) {
continue;
}
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
@ -113,9 +104,6 @@ final class WatchPatterns
}
/**
* Given the affected targets, returns every test file in the graph that
* either matches an exact file target or lives under a directory target.
*
* @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string>
@ -149,16 +137,146 @@ final class WatchPatterns
return $affected;
}
public function markEnabled(): void
{
$this->enabled = true;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function markLocally(): void
{
$this->locally = true;
}
public function isLocally(): bool
{
return $this->locally;
}
public function markFiltered(): void
{
$this->filtered = true;
}
public function isFiltered(): bool
{
return $this->filtered;
}
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void
{
$this->patterns = [];
$this->parsed = [];
$this->enabled = false;
$this->locally = false;
$this->filtered = false;
$this->baselined = false;
}
private function keyMatches(string $key, string $file): bool
{
$rule = $this->parse($key);
if (! $this->globMatches($rule['include'], $file)) {
return false;
}
$file = str_replace('\\', '/', $file);
if ($this->touchesVcs($file)) {
return false;
}
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
return false;
}
foreach ($rule['excludes'] as $exclude) {
if ($this->excludeMatches($exclude, $file)) {
return false;
}
}
return true;
}
/**
* Matches a project-relative file against a glob pattern.
*
* Supports `*` (single segment), `**` (any depth) and `?`.
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
*/
private function parse(string $key): array
{
if (isset($this->parsed[$key])) {
return $this->parsed[$key];
}
$tokens = preg_split('/\s+/', trim($key)) ?: [];
$include = '';
$excludes = [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if ($token[0] === '!') {
$excludes[] = substr($token, 1);
continue;
}
if ($include === '') {
$include = $token;
}
}
return $this->parsed[$key] = [
'include' => $include,
'excludes' => $excludes,
'allowDotfiles' => $this->patternTargetsDotfiles($include),
];
}
private function patternTargetsDotfiles(string $pattern): bool
{
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function touchesVcs(string $file): bool
{
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
}
private function touchesDotfile(string $file): bool
{
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function excludeMatches(string $exclude, string $file): bool
{
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
if ($this->globMatches($pattern, $file)) {
return true;
}
return $this->globMatches($exclude, basename($file));
}
private function globMatches(string $pattern, string $file): bool
{
$pattern = str_replace('\\', '/', $pattern);

View File

@ -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, '/\\');
}
}

View File

@ -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),
);
}
}

View File

@ -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();
}
}

View File

@ -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) {}

View File

@ -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()));
}
}

View File

@ -6,30 +6,22 @@ namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PreparationStartedSubscriber;
/**
* Starts a per-test recording window on Prepared. Sibling subscribers
* (`EnsureTia*`) close it with the outcome and the assertion count so the
* graph can persist everything needed for faithful replay.
*
* Why one subscriber per event: PHPUnit's `TypeMap::map()` picks only the
* first subscriber interface it finds on a class, so one class cannot fan
* out to multiple events — each event needs its own subscriber class.
*
* @internal
*/
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Prepared $event): void
public function notify(PreparationStarted $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->testPrepared($test->className().'::'.$test->methodName());
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
}
}
}

View File

@ -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) {}

View File

@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Report\Facade;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
@ -89,20 +90,24 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
// If TIA's marker is present, this run executed only the affected
// tests. Merge their fresh coverage slice into the cached full-run
// snapshot (stored by the previous `--tia --coverage` pass) so the
// report reflects the entire suite, not just what re-ran.
CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
// @phpstan-ignore-next-line
if (is_array($codeCoverage)) {
$facade = Facade::fromSerializedData($codeCoverage);
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
/** @var Directory<File|Directory> $report */
$report = (fn (): Directory => $this->report)->call($facade);
} else {
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
}
$totalCoverage = $report->percentageOfExecutedLines();
foreach ($report->getIterator() as $file) {
if (! $file instanceof File) {

View 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));
}
}

View File

@ -50,7 +50,7 @@ final class HigherOrderMessage
}
if ($this->hasHigherOrderCallable()) {
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
}
try {

View File

@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
*/
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
{
$this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition);
$this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments)->when($condition);
}
/**

View File

@ -38,7 +38,7 @@ final class HigherOrderTapProxy
return $this->target->{$property};
}
$className = (new ReflectionClass($this->target))->getName();
$className = new ReflectionClass($this->target)->getName();
if (str_starts_with($className, 'P\\')) {
$className = substr($className, 2);
@ -60,7 +60,7 @@ final class HigherOrderTapProxy
$filename = Backtrace::file();
$line = Backtrace::line();
return (new HigherOrderMessage($filename, $line, $methodName, $arguments))
return new HigherOrderMessage($filename, $line, $methodName, $arguments)
->call($this->target);
}
}

View File

@ -181,7 +181,7 @@ final class Reflection
*/
public static function getFunctionArguments(Closure $function): array
{
$parameters = (new ReflectionFunction($function))->getParameters();
$parameters = new ReflectionFunction($function)->getParameters();
$arguments = [];
foreach ($parameters as $parameter) {
@ -207,7 +207,7 @@ final class Reflection
public static function getFunctionVariable(Closure $function, string $key): mixed
{
return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null;
return new ReflectionFunction($function)->getStaticVariables()[$key] ?? null;
}
/**

View File

@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered;
@ -40,11 +41,16 @@ final class StateGenerator
}
foreach ($testResult->testFailedEvents() as $testResultEvent) {
$state->add(TestResult::fromPestParallelTestCase(
$testResultEvent->test(),
TestResult::FAIL,
$testResultEvent->throwable()
));
if ($testResultEvent instanceof Failed) {
$state->add(TestResult::fromPestParallelTestCase(
$testResultEvent->test(),
TestResult::FAIL,
$testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
}
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);

View File

@ -1,178 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
* behaviour of composer, phpstan, rector, psalm and pint.
*
* Xdebug imposes a 3050% runtime tax on every PHP process that loads it —
* even when nothing is actively tracing, profiling or breaking. Plain `pest`
* users might rely on Xdebug being loaded (IDE breakpoints, step-through
* debugging, custom tooling), so we intentionally leave non-TIA runs alone.
*
* The guard engages only when ALL of these hold:
* 1. `--tia` is present in argv.
* 2. No `--fresh` flag (forced record always drives the coverage
* driver; dropping Xdebug would break the recording).
* 3. No `--coverage*` flag (coverage runs need the driver regardless).
* 4. A valid graph already exists on disk AND its structural fingerprint
* matches the current environment — i.e. TIA will replay rather than
* record. Record runs need the driver.
* 5. Xdebug's configured mode is either empty or exactly `['coverage']`.
* Any other mode (debug, develop, trace, profile, gcstats) signals the
* user wants Xdebug for reasons unrelated to coverage, so we leave it
* alone even on replay.
*
* `PEST_ALLOW_XDEBUG=1` remains an explicit manual override; it is honoured
* natively by `composer/xdebug-handler`.
*
* @internal
*/
final class XdebugGuard
{
/**
* Call as early as possible after composer autoload, before any Pest
* class beyond the autoloader is touched. Safe when Xdebug is not
* loaded (returns immediately) and when `composer/xdebug-handler` is
* unavailable (defensive `class_exists` check).
*/
public static function maybeDrop(string $projectRoot): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! self::xdebugIsCoverageOnly()) {
return;
}
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
if (! self::runLooksDroppable($argv, $projectRoot)) {
return;
}
(new XdebugHandler('pest'))->check();
}
/**
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
* for older Xdebug without `xdebug_info` — be conservative and leave it
* loaded; we can't prove the mode is safe to drop.
*/
private static 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'];
}
/**
* Encodes the argv-based rules: `--tia` must be present, no coverage
* flag, no forced rebuild, and TIA must be about to replay rather than
* record. Plain `pest` (and anything else without `--tia`) keeps Xdebug
* loaded so non-TIA users aren't surprised by behaviour changes.
*
* @param array<int, mixed> $argv
*/
private static function runLooksDroppable(array $argv, string $projectRoot): bool
{
$hasTia = false;
foreach ($argv as $value) {
if (! is_string($value)) {
continue;
}
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--fresh') {
return false;
}
if ($value === '--tia') {
$hasTia = true;
}
}
if (! $hasTia) {
return false;
}
return self::tiaWillReplay($projectRoot);
}
/**
* True when a valid TIA graph already lives on disk AND its structural
* fingerprint matches the current environment. Any other outcome
* (missing graph, unreadable JSON, structural drift) means TIA will
* record and the driver must stay loaded.
*/
private static function tiaWillReplay(string $projectRoot): bool
{
$path = self::graphPath($projectRoot);
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),
);
}
/**
* On-disk location of the TIA graph — delegates to {@see Storage} so
* the writer (TIA's bootstrapper) and this reader stay in sync
* without a runtime container lookup (the container isn't booted yet
* at this point).
*/
private static function graphPath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph;
/**
* @internal
*/
final readonly class TiaTestCaseFilter implements TestCaseFilter
{
/**
* @param array<string, true> $affectedTestFiles Keys are project-relative test file paths.
*/
public function __construct(
private string $projectRoot,
private Graph $graph,
private array $affectedTestFiles,
) {}
public function accept(string $testCaseFilename): bool
{
$rel = $this->relative($testCaseFilename);
if ($rel === null) {
return true;
}
if (! $this->graph->knowsTest($rel)) {
return true;
}
return isset($this->affectedTestFiles[$rel]);
}
private function relative(string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
use Pest\TestsTia\Support\Sandbox;
/*
* Mutating a source file should narrow replay to the tests that depend
* on it. Untouched areas of the suite keep cache-hitting.
*/
test('editing a source file marks only its dependents as affected', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
$sandbox->write('src/Math.php', <<<'PHP'
<?php
declare(strict_types=1);
namespace App;
final class Math
{
public static function add(int $a, int $b): int
{
return $a + $b;
}
public static function sub(int $a, int $b): int
{
return $a - $b;
}
}
PHP);
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
expect(tiaOutput($process))->toMatch('/2 affected,\s*1 replayed/');
});
});
test('adding a new test file runs the new test + replays the rest', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
$sandbox->write('tests/ExtraTest.php', <<<'PHP'
<?php
declare(strict_types=1);
test('extra smoke', function () {
expect(true)->toBeTrue();
});
PHP);
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
expect(tiaOutput($process))->toMatch('/1 affected,\s*3 replayed/');
});
});

View File

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
use Pest\TestsTia\Support\Sandbox;
/*
* Fingerprint splits into structural vs environmental. Hand-forge each
* drift flavour on a valid graph and assert the right branch fires.
*/
test('structural drift discards the graph entirely', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
$graph = json_decode((string) file_get_contents($graphPath), true);
$graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32);
file_put_contents($graphPath, json_encode($graph));
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
expect(tiaOutput($process))->toContain('graph structure outdated');
});
});
test('environmental drift keeps edges, drops results', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
$graphPath = $sandbox->path().'/.pest/tia/graph.json';
$graph = json_decode((string) file_get_contents($graphPath), true);
$edgeCountBefore = count($graph['edges']);
$graph['fingerprint']['environmental']['php_minor'] = '7.4';
$graph['fingerprint']['environmental']['extensions'] = str_repeat('0', 32);
file_put_contents($graphPath, json_encode($graph));
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
expect(tiaOutput($process))->toContain('env differs from baseline');
expect(tiaOutput($process))->toContain('results dropped, edges reused');
$graphAfter = $sandbox->graph();
expect(count($graphAfter['edges']))->toBe($edgeCountBefore);
expect($graphAfter['fingerprint']['environmental']['php_minor'])
->not()->toBe('7.4');
});
});

View File

@ -1,27 +0,0 @@
{
"name": "pest/tia-sample-project",
"type": "project",
"description": "Throw-away fixture used by tests-tia to exercise TIA end-to-end.",
"require": {
"php": "^8.3"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
failOnRisky="false"
failOnWarning="false"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerNotices="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App;
final class Greeter
{
public static function greet(string $name): string
{
return sprintf('Hello, %s!', $name);
}
}

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App;
final class Math
{
public static function add(int $a, int $b): int
{
return $a + $b;
}
}

View File

@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
use App\Greeter;
test('greeter greets', function () {
expect(Greeter::greet('Nuno'))->toBe('Hello, Nuno!');
});

View File

@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
use App\Math;
test('math add', function () {
expect(Math::add(2, 3))->toBe(5);
});
test('math add negative', function () {
expect(Math::add(-1, 1))->toBe(0);
});

View File

@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
// Intentionally minimal — tests-tia exercises TIA against the simplest
// possible Pest harness. Anything more and we end up debugging the
// fixture instead of the feature under test.

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
use Pest\TestsTia\Support\Sandbox;
/*
* `--tia --fresh` short-circuits whatever graph is on disk and records
* from scratch. Used when the user knows the cache is wrong.
*/
test('--tia --fresh forces record mode even with a valid graph', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
expect($sandbox->hasGraph())->toBeTrue();
$graphBefore = $sandbox->graph();
$process = $sandbox->pest(['--tia', '--fresh']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
expect(tiaOutput($process))->toContain('recording dependency graph');
$graphAfter = $sandbox->graph();
expect(array_keys($graphAfter['edges']))
->toEqualCanonicalizing(array_keys($graphBefore['edges']));
});
});

View File

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
use Pest\TestsTia\Support\Sandbox;
/*
* The canonical cycle:
* 1. Cold `--tia` run → record mode → graph written, tests pass.
* 2. Subsequent `--tia` runs → replay mode, every test cache-hits.
*/
test('cold run records the graph', function () {
tiaScenario(function (Sandbox $sandbox) {
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
expect(tiaOutput($process))->toContain('recording dependency graph');
expect($sandbox->hasGraph())->toBeTrue();
$graph = $sandbox->graph();
expect($graph)->toHaveKey('edges');
expect(array_keys($graph['edges']))->toContain('tests/MathTest.php');
expect(array_keys($graph['edges']))->toContain('tests/GreeterTest.php');
});
});
test('warm run replays every test', function () {
tiaScenario(function (Sandbox $sandbox) {
// Cold pass: records edges AND snapshots results (series mode
// runs `snapshotTestResults` in the same `addOutput` pass).
$sandbox->pest(['--tia']);
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeTrue(tiaOutput($process));
// Zero changes → only the `replayed` fragment appears in the
// recap; the `affected` fragment is omitted when count is 0.
expect(tiaOutput($process))->toMatch('/3 replayed/');
expect(tiaOutput($process))->not()->toMatch('/\d+ affected/');
});
});

View File

@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
use Pest\TestsTia\Support\Sandbox;
/*
* Edit a source file, run TIA (tests re-run), revert to the original
* bytes, run again — the revert is itself a change vs the previous
* snapshot, so the affected tests re-execute rather than replaying the
* stale bad-version cache.
*/
test('reverting a modified file re-triggers its affected tests', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
$original = (string) file_get_contents($sandbox->path().'/src/Math.php');
$sandbox->write('src/Math.php', <<<'PHP'
<?php
declare(strict_types=1);
namespace App;
final class Math
{
public static function add(int $a, int $b): int
{
return 999; // broken
}
}
PHP);
$broken = $sandbox->pest(['--tia']);
expect($broken->isSuccessful())->toBeFalse();
$sandbox->write('src/Math.php', $original);
$recovered = $sandbox->pest(['--tia']);
expect($recovered->isSuccessful())->toBeTrue(tiaOutput($recovered));
expect(tiaOutput($recovered))->toMatch('/2 affected,\s*1 replayed/');
});
});

View File

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
use Pest\TestsTia\Support\Sandbox;
/*
* Cached statuses + assertion counts should survive replay.
*/
test('assertion counts survive replay', function () {
tiaScenario(function (Sandbox $sandbox) {
$sandbox->pest(['--tia']);
$process = $sandbox->pest(['--tia']);
$output = tiaOutput($process);
// MathTest has 2 assertions, GreeterTest has 1 → 3 total.
// The "Tests: … (N assertions, … replayed)" banner should show 3.
expect($output)->toMatch('/\(3 assertions/');
});
});
test('breaking a test replays as a failure on the next run', function () {
tiaScenario(function (Sandbox $sandbox) {
// Prime.
$sandbox->pest(['--tia']);
// Break the test. Its test file's edge map still points at
// `src/Math.php`; editing the test file counts as a change
// and the test re-executes.
$sandbox->write('tests/MathTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use App\Math;
test('math add', function () {
expect(Math::add(2, 3))->toBe(999); // wrong
});
test('math add negative', function () {
expect(Math::add(-1, 1))->toBe(0);
});
PHP);
$process = $sandbox->pest(['--tia']);
expect($process->isSuccessful())->toBeFalse();
expect(tiaOutput($process))->toContain('math add');
});
});

View File

@ -1,447 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\TestsTia\Support;
use RuntimeException;
use Symfony\Component\Process\Process;
/**
* Throw-away sandbox for a TIA end-to-end scenario.
*
* On first call in a test run, a shared "template" sandbox is created
* under the system temp dir and composer-installed against the host
* Pest source. Subsequent `::create()` calls clone the template — cheap
* (rcopy + git init) vs. running composer install per test.
*
* Each test owns its own clone; no cross-test state.
*
* Set `PEST_TIA_KEEP=1` to skip teardown so a failing scenario can be
* reproduced manually — the path is emitted to STDERR.
*
* @internal
*/
final class Sandbox
{
private static ?string $templatePath = null;
private function __construct(private readonly string $path) {}
/**
* Eagerly provision the shared template. Call once from the harness
* bootstrap so parallel workers don't race on first `create()`.
*/
public static function warmTemplate(): void
{
self::ensureTemplate();
}
public static function create(): self
{
$template = self::ensureTemplate();
$path = sys_get_temp_dir()
.DIRECTORY_SEPARATOR
.'pest-tia-sandbox-'
.bin2hex(random_bytes(4));
self::rcopy($template, $path);
self::bootstrapGit($path);
return new self($path);
}
public function path(): string
{
return $this->path;
}
public function write(string $relative, string $content): void
{
$absolute = $this->path.DIRECTORY_SEPARATOR.$relative;
$dir = dirname($absolute);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
throw new RuntimeException("Cannot create {$dir}");
}
if (@file_put_contents($absolute, $content) === false) {
throw new RuntimeException("Cannot write {$absolute}");
}
}
public function delete(string $relative): void
{
$absolute = $this->path.DIRECTORY_SEPARATOR.$relative;
if (is_file($absolute)) {
@unlink($absolute);
}
}
/**
* @param array<int, string> $flags
*/
public function pest(array $flags = []): Process
{
// Invoke Pest's bin script through PHP directly rather than the
// `vendor/bin/pest` symlink — `rcopy()` loses the `+x` bit when
// cloning the template. Going through `php` bypasses the exec
// check. Use `PHP_BINARY` (not a bare `php`) so the sandbox
// executes under the same interpreter that launched the outer
// test suite — otherwise macOS multi-version setups (Herd, brew,
// asdf, …) fall back to the first `php` on `$PATH`, which often
// lacks the coverage driver TIA's record mode needs.
$process = new Process(
[PHP_BINARY, 'vendor/pestphp/pest/bin/pest', ...$flags],
$this->path,
[
// Strip any CI signal so TIA doesn't suppress instructions.
'GITHUB_ACTIONS' => '',
'GITLAB_CI' => '',
'CIRCLECI' => '',
// Force TIA's Storage to fall back to the sandbox-local
// `.pest/tia/` layout. Without this, every sandbox run
// would dump state into the developer's real home dir
// (`~/.pest/tia/`), polluting it and making tests
// non-hermetic.
'HOME' => '',
'USERPROFILE' => '',
],
);
$process->setTimeout(120.0);
$process->run();
return $process;
}
/**
* @return array<string, mixed>|null
*/
public function graph(): ?array
{
$path = $this->path.'/.pest/tia/graph.json';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
public function hasGraph(): bool
{
return $this->graph() !== null;
}
/**
* @param array<int, string> $args
*/
public function git(array $args): Process
{
$process = new Process(['git', ...$args], $this->path);
$process->setTimeout(30.0);
$process->run();
return $process;
}
public function destroy(): void
{
if (getenv('PEST_TIA_KEEP') === '1') {
fwrite(STDERR, "[PEST_TIA_KEEP] sandbox: {$this->path}\n");
return;
}
if (is_dir($this->path)) {
self::rrmdir($this->path);
}
}
/**
* Lazily provisions a once-per-process template with composer already
* installed against the host Pest source. Every sandbox clone copies
* from here, avoiding a ~30s composer install per test.
*/
private static function ensureTemplate(): string
{
if (self::$templatePath !== null && is_dir(self::$templatePath.'/vendor')) {
return self::$templatePath;
}
// Cache key includes a fingerprint of the host Pest source tree —
// when we edit Pest internals, the key changes, old templates
// become orphaned, the new template rebuilds. Without this, a
// stale template with yesterday's Pest code silently masks today's
// code under test.
$template = sys_get_temp_dir()
.DIRECTORY_SEPARATOR
.'pest-tia-template-'
.self::hostFingerprint();
// Serialise template creation across parallel paratest workers.
// Without the lock, three workers hitting `ensureTemplate()`
// simultaneously each see "no vendor yet → rebuild", stomp on
// each other's composer install, and produce half-written
// fixtures. `flock` on a sibling lockfile keeps it to one
// builder; the others block, then observe the finished
// template and skip straight to the fast path.
$lockPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template.lock';
$lock = fopen($lockPath, 'c');
if ($lock === false) {
throw new RuntimeException('Cannot open template lock at '.$lockPath);
}
flock($lock, LOCK_EX);
try {
// Re-check after acquiring the lock — another worker may have
// just finished the build while we were waiting.
if (is_dir($template.'/vendor')) {
self::$templatePath = $template;
return $template;
}
// Garbage-collect every older template keyed by a different
// fingerprint so /tmp doesn't accumulate a 200 MB graveyard
// over a month of edits.
foreach (glob(sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template-*') ?: [] as $orphan) {
if ($orphan !== $template) {
self::rrmdir($orphan);
}
}
if (is_dir($template)) {
self::rrmdir($template);
}
$fixture = __DIR__.'/../Fixtures/sample-project';
if (! is_dir($fixture)) {
throw new RuntimeException('Missing fixture at '.$fixture);
}
if (! @mkdir($template, 0755, true) && ! is_dir($template)) {
throw new RuntimeException('Cannot create template at '.$template);
}
self::rcopy($fixture, $template);
self::wireHostPest($template);
self::composerInstall($template);
self::$templatePath = $template;
return $template;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private static function wireHostPest(string $path): void
{
$hostRoot = realpath(__DIR__.'/../..');
if ($hostRoot === false) {
throw new RuntimeException('Cannot resolve host Pest root');
}
$composerJson = $path.'/composer.json';
$decoded = json_decode((string) file_get_contents($composerJson), true);
$decoded['repositories'] = [
['type' => 'path', 'url' => $hostRoot, 'options' => ['symlink' => false]],
];
$decoded['require']['pestphp/pest'] = '*@dev';
file_put_contents(
$composerJson,
json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n",
);
}
private static function composerInstall(string $path): void
{
// Invoke composer via the *same* PHP binary that's running this
// process. On macOS multi-version setups (Herd, brew, asdf, etc.)
// the `composer` shebang often points at the system PHP, which
// may not match the version the test suite booted with — leading
// to "your PHP version does not satisfy the requirement" errors
// even when the interpreter in use would satisfy it. Going
// through `PHP_BINARY` + the located composer binary/phar
// sidesteps that entirely.
$composer = self::locateComposer();
$args = $composer === null
? ['composer', 'install']
: [PHP_BINARY, $composer, 'install'];
$process = new Process(
[...$args, '--no-interaction', '--prefer-dist', '--no-progress', '--quiet'],
$path,
);
$process->setTimeout(600.0);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(
"composer install failed in template:\n".$process->getOutput().$process->getErrorOutput(),
);
}
}
/**
* Resolves the composer binary to a real path PHP can execute. Returns
* `null` when composer isn't findable, in which case the caller falls
* back to invoking plain `composer` via `$PATH` (and hopes for the
* best — usually fine on CI Linux runners).
*/
private static function locateComposer(): ?string
{
$probe = new Process(['command', '-v', 'composer']);
$probe->run();
$path = trim($probe->getOutput());
if ($path === '' || ! is_file($path)) {
return null;
}
// `composer` may be a shell-script wrapper (Herd does this) —
// resolve the actual phar it invokes. Heuristic: parse out the
// last `.phar` argument from the wrapper, fall back to the file
// itself if no wrapper is detected.
$content = @file_get_contents($path);
if ($content !== false && preg_match('/\S+\.phar/', $content, $m) === 1) {
$phar = $m[0];
if (is_file($phar)) {
return $phar;
}
}
return $path;
}
private static function bootstrapGit(string $path): void
{
// Each clone needs its own repo — TIA's SHA / branch / diff logic
// all rely on `.git/`. The template has no git dir so clones start
// from a clean slate.
$run = function (array $args) use ($path): void {
$process = new Process(['git', ...$args], $path);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException('git '.implode(' ', $args).' failed: '.$process->getErrorOutput());
}
};
// `.git` may have been cloned from the template if we ever add one
// there — nuke it just in case so every sandbox starts fresh.
if (is_dir($path.'/.git')) {
self::rrmdir($path.'/.git');
}
// Keep `vendor/` and composer lock out of the sandbox's git repo
// entirely. With ~thousands of files `git add .` takes tens of
// seconds; TIA also ignores vendor paths via `shouldIgnore()` so
// tracking them buys nothing except slowness.
file_put_contents($path.'/.gitignore', "vendor/\ncomposer.lock\n");
$run(['init', '-q', '-b', 'main']);
$run(['config', 'user.email', 'sandbox@pest.test']);
$run(['config', 'user.name', 'Pest Sandbox']);
$run(['config', 'commit.gpgsign', 'false']);
$run(['add', '.']);
$run(['commit', '-q', '-m', 'initial']);
}
/**
* Short hash derived from the host Pest source that the template is
* built against. Hashing the newest mtime across `src/`, `overrides/`,
* and `composer.json` is cheap (one stat each) and catches every edit
* that could alter TIA behaviour.
*/
private static function hostFingerprint(): string
{
$hostRoot = realpath(__DIR__.'/../..');
if ($hostRoot === false) {
return 'unknown';
}
$newest = 0;
foreach ([$hostRoot.'/src', $hostRoot.'/overrides'] as $dir) {
if (! is_dir($dir)) {
continue;
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iter as $file) {
if ($file->isFile()) {
$newest = max($newest, $file->getMTime());
}
}
}
if (is_file($hostRoot.'/composer.json')) {
$newest = max($newest, (int) filemtime($hostRoot.'/composer.json'));
}
return substr(sha1($hostRoot.'|'.PHP_VERSION.'|'.$newest), 0, 12);
}
private static function rcopy(string $src, string $dest): void
{
if (! is_dir($dest) && ! @mkdir($dest, 0755, true) && ! is_dir($dest)) {
throw new RuntimeException("Cannot create {$dest}");
}
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iter as $item) {
$target = $dest.DIRECTORY_SEPARATOR.$iter->getSubPathname();
if ($item->isDir()) {
@mkdir($target, 0755, true);
} else {
copy($item->getPathname(), $target);
}
}
}
private static function rrmdir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
// `rm -rf` shells out but handles symlinks, read-only files, and
// the composer-vendor quirks (lock files, .bin symlinks) that
// PHP's own recursive delete stumbles on. Non-fatal on failure.
$process = new Process(['rm', '-rf', $dir]);
$process->setTimeout(60.0);
$process->run();
}
}

View File

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
/**
* tests-tia bootstrap.
*
* Pest's automatic `Pest.php` loader scans the configured `testDirectory()`
* which defaults to `tests/` and is hard to override from a nested suite.
* So instead of relying on `tests-tia/Pest.php` being found, wire the
* helpers in via PHPUnit's explicit `bootstrap=` attribute — simpler,
* no config-search surprises.
*/
require __DIR__.'/../vendor/autoload.php';
require __DIR__.'/Support/Sandbox.php';
use Pest\TestsTia\Support\Sandbox;
use Symfony\Component\Process\Process;
// tests-tia exercises the record path end-to-end, which means the
// sandbox PHP must expose a coverage driver (pcov or xdebug with
// coverage mode). Without one, `--tia` records zero edges and every
// scenario assertion fails with a useless "no coverage driver" banner.
// Bail out loudly at bootstrap so the failure mode is obvious.
if (! extension_loaded('pcov') && ! extension_loaded('xdebug')) {
fwrite(STDERR, "\n");
fwrite(STDERR, " \e[30;43m SKIP \e[0m tests-tia requires a coverage driver (pcov or xdebug).\n");
fwrite(STDERR, " Install one, then retry: composer test:tia\n\n");
// Exit 0 so CI doesn't fail when the driver is genuinely absent —
// the CI workflow adds pcov explicitly so this branch only fires on
// dev machines that haven't set one up.
exit(0);
}
// Pre-warm the shared composer template once, up-front. Without this,
// parallel workers race on first use — whoever hits `ensureTemplate()`
// second gets a half-written template. A file-based lock + single
// bootstrap pre-warm sidesteps the problem entirely.
Sandbox::warmTemplate();
/**
* Runs `$body` inside a fresh sandbox, guaranteeing teardown even when the
* body throws. Keeps scenario tests tidy — one line per setup + destroy.
*/
function tiaScenario(Closure $body): void
{
$sandbox = Sandbox::create();
try {
$body($sandbox);
} finally {
$sandbox->destroy();
}
}
/**
* Strip ANSI escapes so assertions are terminal-agnostic.
*/
function tiaOutput(Process $process): string
{
$output = $process->getOutput().$process->getErrorOutput();
return preg_replace('/\e\[[0-9;]*m/', '', $output) ?? $output;
}

View File

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="bootstrap.php"
colors="true"
cacheDirectory="../.phpunit.cache/tests-tia"
executionOrder="default"
failOnRisky="false"
failOnWarning="false">
<testsuites>
<testsuite name="tia">
<directory>.</directory>
<exclude>Fixtures</exclude>
<exclude>Support</exclude>
</testsuite>
</testsuites>
</phpunit>

Some files were not shown because too many files have changed in this diff Show More