Compare commits

...

149 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
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 3509 additions and 4866 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: on:
push: push:
branches: [4.x] branches: [5.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 9 * * *'
@ -24,16 +24,16 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
dependency-version: [prefer-lowest, prefer-stable] dependency-version: [prefer-stable]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
with: with:
php-version: 8.3 php-version: 8.4
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets extensions: sockets
@ -44,13 +44,13 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} 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: | restore-keys: |
static-php-8.3-${{ matrix.dependency-version }}-composer- static-php-8.4-${{ matrix.dependency-version }}-composer-
static-php-8.3-composer- static-php-8.4-composer-
- name: Install Dependencies - name: Install Dependencies
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi

View File

@ -2,7 +2,7 @@ name: Tests
on: on:
push: push:
branches: [4.x] branches: [5.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 9 * * *'
@ -24,21 +24,18 @@ jobs:
fail-fast: true fail-fast: true
matrix: matrix:
os: [ubuntu-latest, macos-latest] # windows-latest os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['7.4', '8.0'] symfony: ['8.0']
php: ['8.3', '8.4', '8.5'] php: ['8.4', '8.5']
dependency_version: [prefer-stable] dependency_version: [prefer-stable]
exclude:
- php: '8.3'
symfony: '8.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
@ -51,7 +48,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v5 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }} key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
@ -76,21 +73,3 @@ jobs:
- name: Integration Tests - name: Integration Tests
run: composer test:integration 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="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://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://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>
</p> </p>

View File

@ -3,8 +3,10 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Contracts\Restarter;
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
@ -143,14 +145,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $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; $input = new ArgvInput;
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
@ -201,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try { try {
$kernel = Kernel::boot($testSuite, $input, $output); $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); $result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate(); $kernel->terminate();

View File

@ -1,55 +1,62 @@
#!/usr/bin/env node #!/usr/bin/env node
/** import { readdir, readFile } from 'node:fs/promises'
* 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 { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { createRequire } from 'node:module' 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' 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 PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/') const PAGE_DIR_CANDIDATES = [
const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10) '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 async function loadRolldown() {
// 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() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const vitePath = projectRequire.resolve('vite') const path = projectRequire.resolve('rolldown')
return await import(pathToFileURL(vitePath).href) 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) { async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return [] if (!existsSync(pagesDir)) return []
@ -69,14 +76,44 @@ async function listPageFiles(pagesDir) {
return out 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) { function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/') const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel) const ext = extname(rel)
return rel.slice(0, rel.length - ext.length) 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() { 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) const pages = await listPageFiles(pagesDir)
if (pages.length === 0) { if (pages.length === 0) {
@ -84,82 +121,104 @@ async function main() {
return return
} }
// Boot Vite in middleware mode (no port binding, no HMR server). const { rolldown } = await loadRolldown()
// We only need the module graph; transformRequest per page warms const alias = await loadAliasFromTsconfig()
// it without running a bundle. const aliasKeys = Object.keys(alias)
const server = await createServer({
configFile: undefined, // auto-detect vite.config.* const graph = new Map()
root: PROJECT_ROOT,
logLevel: 'silent', const collector = {
clearScreen: false, name: 'pest-tia-collector',
server: { moduleParsed(info) {
middlewareMode: true, const id = info.id
hmr: false, if (!id || id.startsWith('\0')) return
watch: null, 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 { const externalBare = {
for (const pagePath of pages) { name: 'pest-tia-external-bare',
const pageComponent = pageComponentCache.get(pagePath) resolveId(source) {
const pageUrl = '/' + posix.relative( if (!source) return null
PROJECT_ROOT.split(sep).join('/'), if (isLocalSpecifier(source, aliasKeys)) return null
pagePath.split(sep).join('/'), return { id: source, external: true }
) },
}
try { const assetStub = {
await server.transformRequest(pageUrl, { ssr: false }) name: 'pest-tia-asset-stub',
} catch { load(id) {
// Transform errors (missing deps, syntax issues) shouldn't if (!id) return null
// poison the whole graph — skip this page and continue. if (ASSET_EXT_RE.test(id)) {
continue return { code: 'export default null', moduleSideEffects: false }
} }
return null
},
}
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false) const input = Object.create(null)
if (!pageModule) continue for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
// BFS over importedModules, scoped to files inside the project. const bundle = await rolldown({
const visited = new Set() input,
const queue = [pageModule] cwd: PROJECT_ROOT,
while (queue.length) { resolve: {
const mod = queue.shift() alias,
for (const imported of mod.importedModules) { extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
const id = imported.file ?? imported.id },
if (!id || visited.has(id)) continue transform: { jsx: 'preserve' },
visited.add(id) treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
// Skip files outside the project root (node_modules, etc.) try {
// and virtual modules (`\0`-prefixed ids from plugins). await bundle.generate({ format: 'esm' })
if (id.startsWith('\0')) continue } finally {
if (!id.startsWith(PROJECT_ROOT)) continue await bundle.close()
}
const rel = relative(PROJECT_ROOT, id).split(sep).join('/') const reverse = new Map()
const bucket = reverse.get(rel) ?? new Set() const transitiveCache = new Map()
bucket.add(pageComponent)
reverse.set(rel, bucket)
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 { stack.delete(id)
clearTimeout(killer) transitiveCache.set(id, acc)
await server.close() 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) const payload = Object.create(null)
@ -172,8 +231,7 @@ async function main() {
} }
try { try {
// Node 20 dynamic-import path — some environments are pickier than others. void pathToFileURL
void pathToFileURL // retained to silence tree-shakers referencing the import
await main() await main()
} catch (err) { } catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))

View File

@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel; use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput; 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'])); assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb'); $statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile)); assert(is_resource($statusFile));

View File

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

View File

@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], '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\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaCoverageIsRecorded::class, Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
Subscribers\EnsureTiaCoverageIsFlushed::class, Subscribers\EnsureTiaStarts::class,
Subscribers\EnsureTiaEnds::class,
Subscribers\EnsureTiaResultsAreCollected::class, Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class, Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class, Subscribers\EnsureTiaResultIsRecordedOnFailed::class,

View File

@ -8,11 +8,9 @@ use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\AutoloadEdges; use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\BladeEdges; use Pest\Plugins\Tia\Enums\ReplayType;
use Pest\Plugins\Tia\InertiaEdges;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\TableTracker;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container; use Pest\Support\Container;
@ -84,10 +82,15 @@ trait Testable
public bool $__ran = false; public bool $__ran = false;
/** /**
* Set when a `BeforeEachable` plugin returns a cached success result. * The active replay mode for this test, set in `setUp()` and checked
* Checked in `__runTest` and `tearDown` to skip body + cleanup. * 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. * The test's test closure.
@ -241,8 +244,6 @@ trait Testable
{ {
TestSuite::getInstance()->test = $this; TestSuite::getInstance()->test = $this;
$this->__cachedPass = false;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description; $description = $method->description;
@ -275,75 +276,34 @@ trait Testable
self::$__latestIssues = $method->issues; self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs; 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 */ /** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class); $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 ($replay !== ReplayType::None) {
if ($cached->isSuccess()) { assert($status !== null);
$this->__cachedPass = true;
$this->__ran = true;
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. return;
// Best available: short-circuit as a pass so the test doesn't
// misreport as a failure. Aggregate risky totals won't
// survive replay — accepted trade-off until PHPUnit grows a
// programmatic risky-marker API.
if ($cached->isRisky()) {
$this->__cachedPass = true;
$this->__ran = true;
return;
}
// Non-success: throw the matching PHPUnit exception. Runner
// catches it and marks the test with the correct status so
// skips, failures, incompletes and todos appear in output
// exactly as they did in the cached run.
if ($cached->isSkipped()) {
$this->markTestSkipped($cached->message());
}
if ($cached->isIncomplete()) {
$this->markTestIncomplete($cached->message());
$this->__ran = true;
}
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
} }
$recorder = Container::getInstance()->get(Recorder::class); $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); $recorder->beginTest($this::class, $this->name(), self::$__filename);
} }
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
? AutoloadEdges::snapshot()
: [];
parent::setUp(); parent::setUp();
// TIA blade-edge + table-edge recording (Laravel-only). Runs Collectors::armAll($recorder);
// 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);
}
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
@ -352,18 +312,13 @@ trait Testable
} }
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
}
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) { private function __beginReplay(ReplayType $replay, Tia $tia): void
$recorder->linkSourcesForTest( {
self::$__filename, $this->__replay = $replay;
AutoloadEdges::newProjectFiles( $this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$autoloadBeforeSetUp, $this->__ran = true;
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
self::$__filename,
),
);
}
} }
/** /**
@ -398,7 +353,7 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
if ($this->__cachedPass) { if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null; TestSuite::getInstance()->test = null;
return; return;
@ -429,19 +384,12 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__cachedPass) { if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
// Feed the exact assertion count captured during the recorded if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
// 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) {
$this->expectNotToPerformAssertions(); $this->expectNotToPerformAssertions();
} }
$this->addToAssertionCount($assertions); $this->addToAssertionCount($this->__replayAssertions);
return null; return null;
} }

View File

@ -33,7 +33,7 @@ final readonly class Configuration
*/ */
public function in(string ...$targets): UsesCall 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 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 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

@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{ {
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void public function render(OutputInterface $output): void
{ {
$output->writeln([ $output->writeln([
@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc
]); ]);
} }
/**
* The exit code to be used.
*/
public function exitCode(): int public function exitCode(): int
{ {
return 0; 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) { if ($callbacks[$index] instanceof Closure) {
$callbacks[$index](new self($value), new self($key)); $callbacks[$index](new self($value), new self($key));
} else { } else {
(new self($value))->toEqual($callbacks[$index]); new self($value)->toEqual($callbacks[$index]);
} }
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0; $index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
@ -915,15 +915,7 @@ final class Expectation
return Targeted::make( return Targeted::make(
$this, $this,
function (ObjectDescription $object) use ($interfaces): bool { fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
foreach ($interfaces as $interface) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"to implement '".implode("', '", $interfaces)."'", "to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -1138,8 +1130,8 @@ final class Expectation
$this, $this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum() && $object->reflectionClass->isEnum()
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line && new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line && (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum', 'to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );

View File

@ -576,15 +576,7 @@ final readonly class OppositeExpectation
return Targeted::make( return Targeted::make(
$original, $original,
function (ObjectDescription $object) use ($traits): bool { fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true))),
foreach ($traits as $trait) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
"not to use traits '".implode("', '", $traits)."'", "not to use traits '".implode("', '", $traits)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -604,15 +596,7 @@ final readonly class OppositeExpectation
return Targeted::make( return Targeted::make(
$original, $original,
function (ObjectDescription $object) use ($interfaces): bool { fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface))),
foreach ($interfaces as $interface) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"not to implement '".implode("', '", $interfaces)."'", "not to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -814,13 +798,11 @@ final readonly class OppositeExpectation
$exporter = Exporter::default(); $exporter = Exporter::default();
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
throw new ExpectationFailedException(sprintf( throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.', 'Expecting %s not %s %s.',
$toString($this->original->value), $exporter->shortenedExport($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)), 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, $original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $object->reflectionClass->isEnum() || ! $object->reflectionClass->isEnum()
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line || ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line || (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
'not to be '.$backingType.' backed enum', 'not to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), 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 { final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode $traitsCode
private static \$__filename = '$filename'; public static \$__filename = '$filename';
$methodsCode $methodsCode
} }
@ -197,7 +197,7 @@ final class TestCaseFactory
if ( if (
$method->closure instanceof \Closure && $method->closure instanceof \Closure &&
(new \ReflectionFunction($method->closure))->isStatic() new \ReflectionFunction($method->closure)->isStatic()
) { ) {
throw new TestClosureMustNotBeStatic($method); throw new TestClosureMustNotBeStatic($method);

View File

@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/** /**
* @internal * @internal
*/ */
final readonly class Kernel final class Kernel
{ {
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/** /**
* The Kernel bootstrappers. * The Kernel bootstrappers.
* *
@ -36,6 +41,7 @@ final readonly class Kernel
*/ */
private const array BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class, Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,
@ -44,15 +50,22 @@ final readonly class Kernel
Bootstrappers\BootExcludeList::class, 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. * Creates a new Kernel instance.
*/ */
public function __construct( public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
private Application $application,
private OutputInterface $output,
) {
//
}
/** /**
* Boots the Kernel. * Boots the Kernel.
@ -113,9 +126,13 @@ final readonly class Kernel
$configuration = Registry::get(); $configuration = Registry::get();
$result = Facade::result(); $result = Facade::result();
return CallsAddsOutput::execute( $result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result), Result::exitCode($configuration, $result),
); );
$this->terminate();
return $result;
} }
/** /**
@ -123,6 +140,12 @@ final readonly class Kernel
*/ */
public function terminate(): void public function terminate(): void
{ {
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class); $preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump); assert($preBufferOutput instanceof KernelDump);
@ -140,7 +163,7 @@ final readonly class Kernel
$this->terminate(); $this->terminate();
if (is_array($error = error_get_last())) { 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; return;
} }

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest; namespace Pest;
use Laravel\Pao\Execution;
use Pest\Support\View; use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -28,6 +29,10 @@ final class KernelDump
*/ */
public function enable(): void public function enable(): void
{ {
if (class_exists(Execution::class) && Execution::running()) {
return;
}
ob_start(function (string $message): string { ob_start(function (string $message): string {
$this->buffer .= $message; $this->buffer .= $message;
@ -68,6 +73,10 @@ final class KernelDump
$type = 'INFO'; $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)) { if ($this->isInternalError($this->buffer)) {
$type = 'ERROR'; $type = 'ERROR';
$this->buffer = str_replace( $this->buffer = str_replace(
@ -107,7 +116,6 @@ final class KernelDump
*/ */
private function isInternalError(string $output): bool private function isInternalError(string $output): bool
{ {
return str_contains($output, 'An error occurred inside PHPUnit.') return str_contains($output, 'An error occurred inside PHPUnit.');
|| str_contains($output, 'Fatal error');
} }
} }

View File

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

View File

@ -936,7 +936,7 @@ final class Expectation
if ($exception instanceof Closure) { if ($exception instanceof Closure) {
$callback = $exception; $callback = $exception;
$parameters = (new ReflectionFunction($exception))->getParameters(); $parameters = new ReflectionFunction($exception)->getParameters();
if (count($parameters) !== 1) { if (count($parameters) !== 1) {
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.'); 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) { } catch (Throwable $e) {
if ($exception instanceof Throwable) { if ($exception instanceof Throwable) {
// @phpstan-ignore-next-line
expect($e) expect($e)
->toBeInstanceOf($exception::class, $message) ->toBeInstanceOf($exception::class, $message)
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $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); $varType = $scope->getType($expr->var);
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) { if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
return null; return null;
} }

View File

@ -53,9 +53,7 @@ final class UsesCall
$this->targets = [$filename]; $this->targets = [$filename];
} }
/** #[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
* @deprecated Use `pest()->printer()->compact()` instead.
*/
public function compact(): self public function compact(): self
{ {
DefaultPrinter::compact(true); DefaultPrinter::compact(true);

View File

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

View File

@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
{ {
$arguments = new ArgvInput; $arguments = new ArgvInput;
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) { return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
if ($arguments->hasParameterOption($unsupportedArgument)) {
return true;
}
}
return false;
} }
/** /**

View File

@ -7,7 +7,6 @@ namespace Pest\Plugins\Parallel\Paratest;
use const DIRECTORY_SEPARATOR; use const DIRECTORY_SEPARATOR;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection; use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger; use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer; use ParaTest\JUnit\Writer;
use ParaTest\Options; use ParaTest\Options;
@ -25,11 +24,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\Util\ExcludeList; 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 SebastianBergmann\Timer\Timer;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use function array_filter;
use function array_merge; use function array_merge;
use function array_merge_recursive; use function array_merge_recursive;
use function array_shift; use function array_shift;
@ -146,7 +151,6 @@ final class WrapperRunner implements RunnerInterface
public function run(): int public function run(): int
{ {
$directory = dirname(__DIR__); $directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory); ExcludeList::addDirectory($directory);
TestResultFacade::init(); TestResultFacade::init();
EventFacade::instance()->seal(); EventFacade::instance()->seal();
@ -448,10 +452,33 @@ final class WrapperRunner implements RunnerInterface
return; return;
} }
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage()); $coverageFiles = [];
foreach ($this->coverageFiles as $coverageFile) { foreach ($this->coverageFiles as $fileInfo) {
$coverageMerger->addCoverageFromFile($coverageFile); $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( $coverageManager->generateReports(
$this->printer->printer, $this->printer->printer,

View File

@ -187,11 +187,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
private function allTests(array $arguments): array private function allTests(array $arguments): array
{ {
$output = (new Process([ $output = new Process([
'php', 'php',
...$this->removeParallelArguments($arguments), ...$this->removeParallelArguments($arguments),
'--list-tests', '--list-tests',
]))->setTimeout(120)->mustRun()->getOutput(); ])->setTimeout(120)->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches); 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,20 +4,15 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Composer\InstalledVersions; use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
* Downloads a team-shared TIA baseline from GitHub workflow artifacts so new contributors and
* fresh CI workspaces start in replay mode. Artifacts are used instead of releases because they
* produce no tag (no push cascade), support tunable retention, and can only be published by CI.
*
* Fingerprint validation happens in `Tia::handleParent` after the blobs land; a mismatched
* environment falls through to the normal record path.
*
* @internal * @internal
*/ */
final readonly class BaselineSync final readonly class BaselineSync
@ -30,28 +25,51 @@ final readonly class BaselineSync
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
// Subdirectory under the per-project state dir (`~/.pest/tia/<project>/`)
// where artifacts from previous downloads are kept (one subfolder per
// workflow run id). Hitting the same run id on a later fetch skips
// the `gh run download` round trip entirely — artifacts are immutable
// per run id, so the cached bytes are exactly what gh would re-download.
private const string DOWNLOAD_CACHE_DIR = 'artifacts'; private const string DOWNLOAD_CACHE_DIR = 'artifacts';
// Most recently downloaded artifacts to retain on disk. Branch
// switches and partial baseline rollouts hop across run ids — keeping
// the last few avoids re-downloading when the user toggles between
// them. Older entries get evicted on the next download.
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5; private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
// 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`.
private const int FETCH_COOLDOWN_SECONDS = 86400; private const 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( public function __construct(
private State $state, private State $state,
private OutputInterface $output, private OutputInterface $output,
) {} ) {}
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool private function renderBadge(string $type, string $content): void
{
View::render('components.badge', ['type' => $type, 'content' => $content]);
}
private function renderChild(string $text): void
{
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{ {
$repo = $this->detectGitHubRepo($projectRoot); $repo = $this->detectGitHubRepo($projectRoot);
@ -60,25 +78,23 @@ final readonly class BaselineSync
} }
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. ' 'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
.'Override with <fg=cyan>--refetch</>.',
$this->formatDuration($remaining), $this->formatDuration($remaining),
)); ));
return false; return false;
} }
$this->output->writeln(sprintf( $result = $this->download($repo, $projectRoot, $hasAnchor);
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…', $payload = $result['payload'];
$repo, $failureKind = $result['failureKind'];
));
$payload = $this->download($repo, $projectRoot);
if ($payload === null) { if ($payload === null) {
$this->startCooldown(); if ($failureKind === 'no-runs' || $failureKind === null) {
$this->emitPublishInstructions($repo); $this->startCooldown();
$this->emitPublishInstructions();
}
return false; return false;
} }
@ -93,11 +109,6 @@ final readonly class BaselineSync
$this->clearCooldown(); $this->clearCooldown();
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
));
return true; return true;
} }
@ -145,45 +156,18 @@ final readonly class BaselineSync
return $seconds.'s'; return $seconds.'s';
} }
private function emitPublishInstructions(string $repo): void private function emitPublishInstructions(): void
{ {
if ($this->isCi()) { if ($this->isCi()) {
$this->output->writeln( $this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
);
return; return;
} }
$yaml = $this->isLaravel() $this->renderBadge('WARN', 'No baseline published yet — recording locally.');
? $this->laravelWorkflowYaml() $this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
: $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]);
} }
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
private function isCi(): bool private function isCi(): bool
{ {
return getenv('GITHUB_ACTIONS') === 'true' return getenv('GITHUB_ACTIONS') === 'true'
@ -191,79 +175,6 @@ final readonly class BaselineSync
|| getenv('CIRCLECI') === 'true'; || getenv('CIRCLECI') === 'true';
} }
private function isLaravel(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
private function laravelWorkflowYaml(): string
{
return <<<'YAML'
name: TIA Baseline
on:
push: { branches: [main] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: xdebug
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
- run: cp .env.example .env
- run: composer install --no-interaction --prefer-dist
- run: php artisan key:generate
- run: ./vendor/bin/pest --parallel --tia --coverage
- name: Stage baseline for upload
shell: bash
run: |
mkdir -p .pest-tia-baseline
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
- uses: actions/upload-artifact@v4
with:
name: pest-tia-baseline
path: .pest-tia-baseline/
retention-days: 30
YAML;
}
private function genericWorkflowYaml(): string
{
return <<<'YAML'
name: TIA Baseline
on:
push: { branches: [main] }
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
baseline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', coverage: xdebug }
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pest --parallel --tia --coverage
- name: Stage baseline for upload
shell: bash
run: |
mkdir -p .pest-tia-baseline
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
- uses: actions/upload-artifact@v4
with:
name: pest-tia-baseline
path: .pest-tia-baseline/
retention-days: 30
YAML;
}
private function detectGitHubRepo(string $projectRoot): ?string private function detectGitHubRepo(string $projectRoot): ?string
{ {
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
@ -299,36 +210,115 @@ YAML;
return null; return null;
} }
/** @return array{graph: string, coverage: ?string}|null */ /**
private function download(string $repo, string $projectRoot): ?array * @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{ {
if (! $this->commandExists('gh')) { $this->validateGhDependencies($hasAnchor);
return null;
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return ['payload' => null, 'failureKind' => $listError['kind']];
} }
$runId = $this->latestSuccessfulRunId($repo);
if ($runId === null) { if ($runId === null) {
return null; return ['payload' => null, 'failureKind' => 'no-runs'];
} }
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId); $runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
// Cache hit: a previous fetch already extracted this run id's
// artifact into the run-specific dir. Read the assets straight
// out of it and skip `gh run download` entirely.
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) { if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
// Bump the dir mtime so trimDownloadCache() treats this run
// id as recently used and doesn't evict it later.
@touch($runCacheDir); @touch($runCacheDir);
return $this->readArtifact($runCacheDir); $this->renderChild(sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
} }
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) { if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return null; return ['payload' => null, 'failureKind' => null];
} }
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
if (! $download['success']) {
return ['payload' => null, 'failureKind' => $download['failureKind']];
}
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
$this->trimDownloadCache($projectRoot);
return ['payload' => $payload, 'failureKind' => null];
}
/**
* @param array{kind: string, message: string} $diagnosis
*/
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
{
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
return;
}
Panic::with(new BaselineFetchFailed(
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
private function validateGhDependencies(bool $hasAnchor): void
{
if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.',
'Install it from https://cli.github.com.',
$hasAnchor,
));
}
if (! $this->ghAuthenticated()) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
$hasAnchor,
));
}
}
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId);
$this->output->writeln('');
$this->renderChild($artifactSize !== null
? sprintf(
'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
)
: sprintf(
'Downloading TIA baseline from %s…',
$repo,
));
$process = new Process([ $process = new Process([
'gh', 'run', 'download', $runId, 'gh', 'run', 'download', $runId,
'-R', $repo, '-R', $repo,
@ -336,29 +326,122 @@ YAML;
'-D', $runCacheDir, '-D', $runCacheDir,
]); ]);
$process->setTimeout(900.0); $process->setTimeout(900.0);
$process->run(); $process->start();
if (! $process->isSuccessful()) { $startedAt = microtime(true);
$this->cleanup($runCacheDir); $tick = 0;
return null; while ($process->isRunning()) {
$this->renderDownloadProgress($startedAt, $tick++);
usleep(120_000);
} }
$process->wait();
$this->clearProgressLine();
if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Baseline download failed — %s',
$diagnosis['message'],
));
return ['success' => false, 'failureKind' => $diagnosis['kind']];
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
*/
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
{
$payload = $this->readArtifact($runCacheDir); $payload = $this->readArtifact($runCacheDir);
if ($payload === null) { if ($payload === null) {
$this->cleanup($runCacheDir); $this->cleanup($runCacheDir);
return null; Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).',
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
$hasAnchor,
));
} }
$this->trimDownloadCache($projectRoot);
return $payload; return $payload;
} }
private function artifactSize(string $repo, string $runId): ?int
{
$process = new Process([
'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
'--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME,
),
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$size = trim($process->getOutput());
return is_numeric($size) ? (int) $size : null;
}
private function renderDownloadProgress(float $startedAt, int $tick): void
{
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
$elapsed = max(0.0, microtime(true) - $startedAt);
$frame = $frames[$tick % count($frames)];
$this->output->write(sprintf(
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
$frame,
$elapsed,
));
}
private function clearProgressLine(): void
{
$this->output->write("\r\033[K");
}
private function dirSize(string $dir): int
{
if (! is_dir($dir)) {
return 0;
}
$total = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isFile()) {
$total += $entry->getSize();
}
}
return $total;
}
/** /**
* @return array{graph: string, coverage: ?string}|null * @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/ */
private function readArtifact(string $dir): ?array private function readArtifact(string $dir): ?array
{ {
@ -376,6 +459,7 @@ YAML;
return [ return [
'graph' => $graph, 'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage, 'coverage' => $coverage === false ? null : $coverage,
'sizeOnDisk' => $this->dirSize($dir),
]; ];
} }
@ -384,11 +468,6 @@ YAML;
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR; return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
} }
/**
* Run ids returned by `gh` are numeric strings, but defend against a
* surprising response by stripping anything non-alphanumeric — the
* value is used as a directory name.
*/
private function safeRunId(string $runId): string private function safeRunId(string $runId): string
{ {
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? ''; $sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
@ -396,13 +475,6 @@ YAML;
return $sanitised === '' ? 'unknown' : $sanitised; return $sanitised === '' ? 'unknown' : $sanitised;
} }
/**
* Keep the N most recently used cached artifacts and evict the rest.
* Recency is taken from the directory mtime — `mkdir`/`gh run download`
* stamps it on a fresh entry, and a cache hit `touch`es it back to
* the front of the line, so a frequently-reused run id won't be
* evicted just because newer ids have been seen between uses.
*/
private function trimDownloadCache(string $projectRoot): void private function trimDownloadCache(string $projectRoot): void
{ {
$root = $this->downloadCacheDir($projectRoot); $root = $this->downloadCacheDir($projectRoot);
@ -420,7 +492,7 @@ YAML;
$candidates = []; $candidates = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if (in_array($entry, ['.', '..'], true)) {
continue; continue;
} }
@ -448,7 +520,10 @@ YAML;
} }
} }
private function latestSuccessfulRunId(string $repo): ?string /**
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/
private function latestSuccessfulRunIdWithError(string $repo): array
{ {
$process = new Process([ $process = new Process([
'gh', 'run', 'list', 'gh', 'run', 'list',
@ -463,27 +538,49 @@ YAML;
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return null; return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
} }
$runId = trim($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 private function commandExists(string $cmd): bool
{ {
$probe = new Process(['command', '-v', $cmd]); $process = new Process(['which', $cmd]);
$probe->run(); $process->run();
if ($probe->isSuccessful()) { return $process->isSuccessful();
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
} }
private function cleanup(string $dir): void private function cleanup(string $dir): void
@ -492,13 +589,17 @@ YAML;
return; return;
} }
$entries = glob($dir.DIRECTORY_SEPARATOR.'*'); $iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
if ($entries !== false) { /** @var \SplFileInfo $entry */
foreach ($entries as $entry) { foreach ($iterator as $entry) {
if (is_file($entry)) { if ($entry->isDir()) {
@unlink($entry); @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; 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 * @internal
*/ */
final readonly class Bootstrapper implements BootstrapperContract final readonly class Bootstrapper implements BootstrapperContract
@ -28,22 +17,12 @@ final readonly class Bootstrapper implements BootstrapperContract
public function __construct(private Container $container) {} public function __construct(private Container $container) {}
public function boot(): void 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); $testSuite = $this->container->get(TestSuite::class);
assert($testSuite instanceof TestSuite); 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; namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process; 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 * @internal
*/ */
final readonly class ChangedFiles final readonly class ChangedFiles
@ -31,13 +19,12 @@ final readonly class ChangedFiles
* @param array<string, string> $lastRunTree path → content hash from last run. * @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string> * @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 === []) { if ($lastRunTree === []) {
return $files; return $files;
} }
// Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
$candidates = array_fill_keys($files, true); $candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) { foreach (array_keys($lastRunTree) as $snapshotted) {
@ -48,46 +35,30 @@ final readonly class ChangedFiles
foreach (array_keys($candidates) as $file) { foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null; $snapshot = $lastRunTree[$file] ?? null;
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $current = $this->currentHash($file);
$exists = is_file($absolute);
if ($snapshot === null) { if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file; $remaining[] = $file;
continue;
} }
if (! $exists) {
// Always invalidate deletions — a stale cached result from before the deletion
// would persist forever otherwise, even if the snapshot recorded the empty sentinel.
$remaining[] = $file;
continue;
}
$hash = ContentHash::of($absolute);
if ($hash === false) {
$remaining[] = $file;
continue;
}
if ($hash === $snapshot) {
continue;
}
$remaining[] = $file;
} }
return $remaining; 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 * @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash * @return array<string, string> path → xxh128 content hash
*/ */
@ -99,9 +70,6 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) { 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] = ''; $out[$file] = '';
continue; continue;
@ -119,15 +87,9 @@ final readonly class ChangedFiles
/** /**
* @return array<int, string>|null `null` when git is unavailable, or when * @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 public function since(?string $sha): ?array
{ {
if (! $this->gitAvailable()) {
return null;
}
$files = []; $files = [];
if ($sha !== null && $sha !== '') { if ($sha !== null && $sha !== '') {
@ -140,30 +102,17 @@ final readonly class ChangedFiles
$files = array_merge($files, $this->workingTreeChanges()); $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 = []; $unique = [];
foreach ($files as $file) { foreach ($files as $file) {
if ($file === '') { if ($file === '') {
continue; continue;
} }
if ($this->shouldIgnore($file)) {
continue;
}
$unique[$file] = true; $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 !== '') { if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha); return $this->filterBehaviourallyUnchanged($candidates, $sha);
} }
@ -180,18 +129,9 @@ final readonly class ChangedFiles
$remaining = []; $remaining = [];
foreach ($files as $file) { foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $currentHash = $this->currentHash($file);
if (! is_file($absolute)) { if ($currentHash === null) {
// Deleted on disk — a genuine change, keep it.
$remaining[] = $file;
continue;
}
$currentHash = ContentHash::of($absolute);
if ($currentHash === false) {
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -200,16 +140,12 @@ final readonly class ChangedFiles
$baselineContent = $this->contentAtSha($sha, $file); $baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) { if ($baselineContent === null) {
// Couldn't read the baseline (new file, binary, `git show`
// failed). Err on the side of re-running.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
} }
$baselineHash = ContentHash::ofContent($file, $baselineContent); if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
if ($currentHash !== $baselineHash) {
$remaining[] = $file; $remaining[] = $file;
} }
} }
@ -217,12 +153,6 @@ final readonly class ChangedFiles
return $remaining; 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 private function contentAtSha(string $sha, string $path): ?string
{ {
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
@ -236,41 +166,52 @@ final readonly class ChangedFiles
return $process->getOutput(); 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 = [ if ($candidates === []) {
'.pest/', return $candidates;
'.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/',
];
foreach ($prefixes as $prefix) { $process = new Process(
if (str_starts_with($path, (string) $prefix)) { ['git', 'check-ignore', '--no-index', '-z', '--stdin'],
return true; $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 public function currentBranch(): ?string
{ {
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return null; throw new MissingDependency('Tia mode', 'git');
} }
$branch = trim($process->getOutput()); $branch = trim($process->getOutput());
@ -278,14 +219,6 @@ final readonly class ChangedFiles
return $branch === '' || $branch === 'HEAD' ? null : $branch; 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 private function shaIsReachable(string $sha): bool
{ {
$process = new Process( $process = new Process(
@ -294,9 +227,6 @@ final readonly class ChangedFiles
); );
$process->run(); $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; return $process->getExitCode() === 0;
} }
@ -312,7 +242,7 @@ final readonly class ChangedFiles
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return []; throw new MissingDependency('Tia mode', 'git');
} }
return $this->splitLines($process->getOutput()); return $this->splitLines($process->getOutput());
@ -323,14 +253,6 @@ final readonly class ChangedFiles
*/ */
private function workingTreeChanges(): array 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( $process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'], ['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot, $this->projectRoot,
@ -338,7 +260,7 @@ final readonly class ChangedFiles
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return []; throw new MissingDependency('Tia mode', 'git');
} }
$output = $process->getOutput(); $output = $process->getOutput();
@ -361,8 +283,6 @@ final readonly class ChangedFiles
$status = substr($record, 0, 2); $status = substr($record, 0, 2);
$path = substr($record, 3); $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') { if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path; $files[] = $path;
@ -382,15 +302,11 @@ final readonly class ChangedFiles
public function currentSha(): ?string public function currentSha(): ?string
{ {
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return null; throw new MissingDependency('Tia mode', 'git');
} }
$sha = trim($process->getOutput()); $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,26 +7,11 @@ namespace Pest\Plugins\Tia;
use Pest\Support\Container; 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 * @internal
*/ */
final class Configuration final class Configuration
{ {
/** /**
* Activates TIA for every run without requiring the `--tia` CLI flag.
*
* @return $this * @return $this
*/ */
public function always(): self public function always(): self
@ -39,10 +24,6 @@ final class Configuration
} }
/** /**
* Restricts the `always()` activation to local environments only.
* On CI (`--ci` flag or `CI` env var), TIA is skipped even if `always()` is set.
* Explicit `--tia` on the CLI always takes effect regardless.
*
* @return $this * @return $this
*/ */
public function locally(): self public function locally(): self
@ -56,10 +37,6 @@ final class Configuration
} }
/** /**
* In replay mode, instead of short-circuiting cached results for unaffected
* tests, narrows PHPUnit to only the affected files — unaffected tests are
* never loaded. Can also be enabled with the `--filtered` CLI flag.
*
* @return $this * @return $this
*/ */
public function filtered(): self public function filtered(): self
@ -72,9 +49,18 @@ final class Configuration
} }
/** /**
* Adds watch-pattern → test-directory mappings that supplement (or * @return $this
* override) the built-in defaults. */
* 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 * @param array<string, string> $patterns glob → project-relative test dir
* @return $this * @return $this
*/ */

View File

@ -5,33 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; 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 * @internal
*/ */
final class ContentHash 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 public static function of(string $absolute): string|false
{ {
$raw = @file_get_contents($absolute); $raw = @file_get_contents($absolute);
@ -43,11 +20,6 @@ final class ContentHash
return self::ofContent($absolute, $raw); 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 public static function ofContent(string $path, string $raw): string
{ {
$lower = strtolower($path); $lower = strtolower($path);
@ -69,13 +41,6 @@ final class ContentHash
return hash('xxh128', $raw); 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 private static function hashPhpContent(string $raw): string
{ {
$tokens = @token_get_all($raw); $tokens = @token_get_all($raw);
@ -106,14 +71,6 @@ final class ContentHash
return hash('xxh128', $normalised); 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 private static function hashBladeContent(string $raw): string
{ {
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw; $stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
@ -122,17 +79,6 @@ final class ContentHash
return hash('xxh128', trim($stripped)); 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 private static function hashJsContent(string $raw): string
{ {
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw; $stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;

View File

@ -5,43 +5,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts; 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 * @internal
*/ */
interface State 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; 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; 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 delete(string $key): bool;
public function exists(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> * @return list<string>
*/ */
public function keysWithPrefix(string $prefix): array; 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; namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage; use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use ReflectionClass;
use Throwable; 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 * @internal
*/ */
final class CoverageCollector 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> * @var array<string, string|null>
*/ */
private array $classFileCache = []; 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>> * @return array<string, array<int, string>>
*/ */
public function perTestFiles(): array public function perTestFiles(): array
@ -58,9 +39,6 @@ final class CoverageCollector
$edges = []; $edges = [];
foreach ($lineCoverage as $sourceFile => $lines) { 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 = []; $testIds = [];
foreach ($lines as $hits) { foreach ($lines as $hits) {
@ -100,9 +78,6 @@ final class CoverageCollector
private function testIdToFile(string $testId): ?string 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, '#'); $hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash); $identifier = $hash === false ? $testId : substr($testId, 0, $hash);
@ -128,25 +103,8 @@ final class CoverageCollector
return null; 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 return $className::$__filename;
// 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;
} }
} }

View File

@ -11,33 +11,6 @@ use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable; 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 * @internal
*/ */
final class CoverageMerger final class CoverageMerger
@ -46,7 +19,7 @@ final class CoverageMerger
{ {
$state = self::state(); $state = self::state();
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) { if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return; return;
} }
@ -55,46 +28,66 @@ final class CoverageMerger
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) { 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); $current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) { 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; 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); $current = self::requireCoverage($reportPath);
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) { if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
return; return;
} }
self::primeUncoveredFiles($cached);
self::primeUncoveredFiles($current);
self::stripCurrentTestsFromCached($cached, $current); self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current); $cached->merge($current);
$serialised = serialize($cached); $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( @file_put_contents(
$reportPath, $reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n", '<?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 private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{ {
$currentIds = self::collectTestIds($current); $currentIds = self::collectTestIds($current);
@ -147,15 +140,12 @@ final class CoverageMerger
return array_keys($ids); return array_keys($ids);
} }
private static function state(): ?State private static function state(): State
{ {
try { $state = Container::getInstance()->get(State::class);
$state = Container::getInstance()->get(State::class); assert($state instanceof State);
} catch (Throwable) {
return null;
}
return $state instanceof State ? $state : null; return $state;
} }
private static function requireCoverage(string $reportPath): ?CodeCoverage 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; 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 * @internal
*/ */
final readonly class FileState implements State final class FileState implements State
{ {
/** private readonly string $rootDir;
* Configured root. May not exist on disk yet; resolved + created on
* the first write. Keeping the raw string lets the instance be built private ?string $resolvedRoot = null;
* before Pest's temp dir has been materialised.
*/
private string $rootDir;
public function __construct(string $rootDir) public function __construct(string $rootDir)
{ {
@ -57,8 +46,6 @@ final readonly class FileState implements State
return false; 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)) { if (! @rename($tmp, $path)) {
@unlink($tmp); @unlink($tmp);
@ -108,33 +95,26 @@ final readonly class FileState implements State
return $keys; 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 public function pathFor(string $key): string
{ {
return $this->rootDir.DIRECTORY_SEPARATOR.$key; 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 private function resolvedRoot(): ?string
{ {
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir); $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 private function ensureRoot(): bool
{ {
if (is_dir($this->rootDir)) { if (is_dir($this->rootDir)) {

View File

@ -4,79 +4,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Symfony\Component\Finder\Finder;
/** /**
* Two-bucket fingerprint for TIA staleness detection.
*
* - **structural**: inputs whose drift means graph *edges* may be wrong → full rebuild.
* `tests/TestCase.php` and `tests/Pest.php` are intentionally absent; they're covered by
* `Recorder::linkAncestorFiles` and the watch pattern, giving precise per-test invalidation.
* - **environmental**: runtime inputs (PHP version, extensions, env files) whose drift means
* edges are still valid but cached results may not reproduce → drop results and re-run.
* Pest's own version is absent; `composer.lock` moves whenever Pest is upgraded.
*
* @internal * @internal
*/ */
final readonly class Fingerprint final readonly class Fingerprint
{ {
// Bump this whenever the set of inputs or the hash algorithm changes, private const int SCHEMA_VERSION = 17;
// so older graphs are invalidated automatically.
//
// v5: ChangedFiles now hashes via `ContentHash` (normalises PHP
// tokens + Blade whitespace/comments) instead of raw bytes.
// Old graphs' run-tree hashes are incompatible and must be
// rebuilt.
// v6: Graph gained per-test table edges (`$testTables`) powering
// surgical migration invalidation. Worker partial shape
// changed to `{files, tables}`. Old graphs have no table
// coverage, which would leave every DB test invalidated by
// any migration change — force a rebuild so the new edges
// are populated.
// v7: Graph gained per-test Inertia page-component edges
// (`$testInertiaComponents`) for surgical page-file
// invalidation. Worker partial now includes an `inertia`
// section. Old graphs have no component edges; without a
// rebuild Vue/React page edits would fall through to the
// broad watch pattern even when precise matching could have
// worked.
// v8: Graph gained `$jsFileToComponents` — reverse dependency
// map computed at record time from Vite's module graph (or
// the PHP fallback) so shared components / layouts /
// composables invalidate the specific pages they're used
// by, not every browser test.
// v9: `ContentHash` now normalises JS/TS/Vue/Svelte comments +
// whitespace. Old graphs' run-tree hashes for those files
// were raw-byte; mixing formats would flag every JS file as
// changed on first run.
// v10: `vite.config.*` hashed into the structural bucket. A
// Vite config change reshapes the module dependency graph
// that `JsModuleGraph` records; without a graph rebuild
// the stored `$jsFileToComponents` map silently goes stale.
// v11: `composer.json` added (autoload-dev / extra discovery
// changes). `tests/TestCase.php` and `tests/Pest.php` are
// intentionally NOT fingerprinted — they're handled by the
// watch pattern + `Recorder::linkAncestorFiles` reflection
// walk, which gives precise per-test invalidation rather
// than a wholesale rebuild that trashes the entire graph.
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
// now hash via `ContentHash::of()` so cosmetic comment +
// whitespace edits don't fire rebuilds. composer.json and
// composer.lock hash a behavioural subset — description,
// keywords, scripts, authors, install timestamps, dist
// URLs etc. no longer drift the structural fingerprint.
// v13: Environment files (`.env`, `.env.testing`, local variants)
// are included in the environmental bucket. They are commonly
// git-ignored, so watch patterns alone cannot reliably notice
// edits; a drift drops cached results and re-executes the suite.
// v14: Node/Vite resolver inputs (`package*.json`, `tsconfig.*`,
// `jsconfig.*`) are included in the structural bucket. They can
// reshape the persisted JS module graph without touching
// `vite.config.*` itself.
private const int SCHEMA_VERSION = 14;
/** /**
* @return array{ * @return array{
* structural: array<string, int|string|null>, * structural: array<string, int|string|null>,
* environmental: array<string, string|null>, * environmental: array<string, int|string|null>,
* } * }
*/ */
public static function compute(string $projectRoot): array public static function compute(string $projectRoot): array
@ -85,21 +25,21 @@ final readonly class Fingerprint
'structural' => [ 'structural' => [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::composerLockHash($projectRoot), 'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), 'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), // 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), // 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'vite_config' => self::viteConfigHash($projectRoot), 'vite_config' => self::viteConfigHash($projectRoot),
'package_json' => self::packageJsonHash($projectRoot), // 'package_json' => self::packageJsonHash($projectRoot),
'package_lock' => self::packageLockHash($projectRoot), 'package_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot), 'js_config' => self::jsConfigHash($projectRoot),
'composer_json' => self::composerJsonHash($projectRoot), // 'composer_json' => self::composerJsonHash($projectRoot),
], ],
'environmental' => [ 'environmental' => [
// Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs. 'php_minor' => PHP_MAJOR_VERSION,
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint($projectRoot), // 'extensions' => self::extensionsFingerprint($projectRoot),
'env_files' => self::envFilesHash($projectRoot), // 'env_files' => self::envFilesHash($projectRoot),
], ],
]; ];
} }
@ -126,30 +66,11 @@ final readonly class Fingerprint
*/ */
public static function structuralDrift(array $stored, array $current): array public static function structuralDrift(array $stored, array $current): array
{ {
$a = self::structuralOnly($stored); return self::detectDrift(
$b = self::structuralOnly($current); self::structuralOnly($stored),
self::structuralOnly($current),
$drifts = []; 'schema',
);
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));
} }
/** /**
@ -159,18 +80,34 @@ final readonly class Fingerprint
*/ */
public static function environmentalDrift(array $stored, array $current): array public static function environmentalDrift(array $stored, array $current): array
{ {
$a = self::environmentalOnly($stored); return self::detectDrift(
$b = self::environmentalOnly($current); 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 = []; $drifts = [];
foreach ($a as $key => $value) { foreach ($a as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (($b[$key] ?? null) !== $value) { if (($b[$key] ?? null) !== $value) {
$drifts[] = $key; $drifts[] = $key;
} }
} }
foreach ($b as $key => $value) { foreach ($b as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) { if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key; $drifts[] = $key;
} }
@ -197,7 +134,6 @@ final readonly class Fingerprint
return self::bucket($fingerprint, 'environmental'); return self::bucket($fingerprint, 'environmental');
} }
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
/** /**
* @param array<string, mixed> $fingerprint * @param array<string, mixed> $fingerprint
* @return array<string, mixed> * @return array<string, mixed>
@ -225,7 +161,11 @@ final readonly class Fingerprint
{ {
$parts = []; $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); $hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) { if ($hash !== null) {
@ -241,6 +181,10 @@ final readonly class Fingerprint
$parts = []; $parts = [];
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) { foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::hashIfExists($projectRoot.'/'.$name); $hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) { if ($hash !== null) {
@ -251,47 +195,9 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
} }
private static function packageJsonHash(string $projectRoot): ?string private static function composerLockHash(string $projectRoot): ?string
{ {
$path = $projectRoot.'/package.json'; return self::trackedHash($projectRoot, 'composer.lock');
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$relevant = [
'type' => $data['type'] ?? null,
'packageManager' => $data['packageManager'] ?? null,
'dependencies' => $data['dependencies'] ?? null,
'devDependencies' => $data['devDependencies'] ?? null,
'optionalDependencies' => $data['optionalDependencies'] ?? null,
'peerDependencies' => $data['peerDependencies'] ?? null,
'overrides' => $data['overrides'] ?? null,
'resolutions' => $data['resolutions'] ?? null,
'imports' => $data['imports'] ?? null,
'exports' => $data['exports'] ?? null,
'browser' => $data['browser'] ?? null,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
} }
private static function packageLockHash(string $projectRoot): ?string private static function packageLockHash(string $projectRoot): ?string
@ -299,7 +205,7 @@ final readonly class Fingerprint
$parts = []; $parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) { foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::hashIfExists($projectRoot.'/'.$name); $hash = self::trackedHash($projectRoot, $name);
if ($hash !== null) { if ($hash !== null) {
$parts[] = $name.':'.$hash; $parts[] = $name.':'.$hash;
@ -309,196 +215,47 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
} }
private static function envFilesHash(string $projectRoot): ?string private static function trackedHash(string $projectRoot, string $relativePath): ?string
{ {
$paths = [ if (! self::isTrackedByGit($projectRoot, $relativePath)) {
$projectRoot.'/.env',
$projectRoot.'/.env.testing',
$projectRoot.'/.env.local',
];
$localVariants = glob($projectRoot.'/.env.*.local');
if (is_array($localVariants)) {
foreach ($localVariants as $path) {
$paths[] = $path;
}
}
$parts = [];
$seen = [];
foreach ($paths as $path) {
if (isset($seen[$path])) {
continue;
}
$seen[$path] = true;
if (! is_file($path)) {
continue;
}
$contents = @file_get_contents($path);
if ($contents === false) {
continue;
}
$parts[] = basename($path).':'.hash('xxh128', $contents);
}
if ($parts === []) {
return null; return null;
} }
sort($parts); return self::hashIfExists($projectRoot.'/'.$relativePath);
return hash('xxh128', implode("\n", $parts));
}
private static function composerJsonHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
$relevantConfig = array_intersect_key($config, [
'platform' => true,
'allow-plugins' => true,
]);
$relevant = [
'autoload' => $data['autoload'] ?? null,
'autoload-dev' => $data['autoload-dev'] ?? null,
'require' => $data['require'] ?? null,
'require-dev' => $data['require-dev'] ?? null,
'extra' => $data['extra'] ?? null,
'repositories' => $data['repositories'] ?? null,
'minimum-stability' => $data['minimum-stability'] ?? null,
'prefer-stable' => $data['prefer-stable'] ?? null,
'config' => $relevantConfig === [] ? null : $relevantConfig,
];
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
private static function composerLockHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.lock';
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
$relevant = [
'platform' => $data['platform'] ?? null,
'platform-dev' => $data['platform-dev'] ?? null,
];
foreach (['packages', 'packages-dev'] as $section) {
if (! isset($data[$section])) {
continue;
}
if (! is_array($data[$section])) {
continue;
}
$packages = [];
foreach ($data[$section] as $package) {
if (! is_array($package)) {
continue;
}
$name = $package['name'] ?? null;
if (! is_string($name)) {
continue;
}
$packages[$name] = [
'version' => $package['version'] ?? null,
'reference' => self::lockReference($package),
'autoload' => $package['autoload'] ?? null,
'autoload-dev' => $package['autoload-dev'] ?? null,
'extra' => $package['extra'] ?? null,
];
}
ksort($packages);
$relevant[$section] = $packages;
}
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
} }
/** /**
* @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'] : []; if (! is_file($projectRoot.'/'.$relativePath)) {
$source = is_array($package['source'] ?? null) ? $package['source'] : []; return false;
$reference = $dist['reference'] ?? $source['reference'] ?? null;
return is_string($reference) ? $reference : null;
}
private static function sortRecursively(mixed &$value): void
{
if (! is_array($value)) {
return;
} }
$isAssoc = ! array_is_list($value); static $cache = [];
if ($isAssoc) { $key = $projectRoot."\0".$relativePath;
ksort($value);
if (isset($cache[$key])) {
return $cache[$key];
} }
foreach ($value as &$child) { if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
self::sortRecursively($child); 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 private static function contentHashOrNull(string $path): ?string
@ -522,66 +279,4 @@ final readonly class Fingerprint
return $hash === false ? null : $hash; return $hash === false ? null : $hash;
} }
// Only hashes `ext-*` entries declared in composer.json — incidental extensions loaded on the
// machine but not declared can't affect suite correctness, so they're excluded to reduce noise.
private static function extensionsFingerprint(string $projectRoot): string
{
$extensions = self::declaredExtensions($projectRoot);
if ($extensions === []) {
return hash('xxh128', '');
}
sort($extensions);
$parts = [];
foreach ($extensions as $name) {
$version = phpversion($name);
$parts[] = $name.'@'.($version === false ? 'missing' : $version);
}
return hash('xxh128', implode("\n", $parts));
}
/** @return list<string> */
private static function declaredExtensions(string $projectRoot): array
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
if ($raw === false) {
return [];
}
$data = json_decode($raw, true);
if (! is_array($data)) {
return [];
}
$extensions = [];
foreach (['require', 'require-dev'] as $section) {
$packages = $data[$section] ?? null;
if (! is_array($packages)) {
continue;
}
foreach (array_keys($packages) as $package) {
if (is_string($package) && str_starts_with($package, 'ext-')) {
$extensions[] = substr($package, 4);
}
}
}
return array_values(array_unique($extensions));
}
} }

View File

@ -4,16 +4,16 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Factories\TestCaseFactory;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface; use PHPUnit\TextUI\Configuration\Registry;
/** /**
* Dependency graph: test file → set<source file>. Skips unchanged tests on replay.
* Source files are indexed by numeric id to keep the on-disk JSON compact.
*
* @internal * @internal
*/ */
final class Graph final class Graph
@ -48,12 +48,14 @@ final class Graph
*/ */
private array $baselines = []; private array $baselines = [];
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
private readonly string $projectRoot; private readonly string $projectRoot;
/** @var array<string, true>|null */ /** @var array<string, true>|null */
private ?array $archTestFiles = null; private ?array $archTestFiles = null;
/** @var array<string, string|false> */
private array $realpathCache = [];
public function __construct(string $projectRoot) public function __construct(string $projectRoot)
{ {
$real = @realpath($projectRoot); $real = @realpath($projectRoot);
@ -85,40 +87,76 @@ final class Graph
*/ */
public function affected(array $changedFiles): array public function affected(array $changedFiles): array
{ {
$normalised = []; [$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles);
$affectedSet = [];
$unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet);
[$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]
= $this->applyInertiaChanges($nonMigrationPaths, $affectedSet);
$unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet);
$this->applyTestFileChanges($nonMigrationPaths, $affectedSet);
$staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet);
$this->applyWatchPatternFallback(
$nonMigrationPaths,
$unparseableMigrations,
$preciselyHandledPages,
$sharedFilesResolved,
$staticallyHandledBlade,
$affectedSet,
);
$this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet);
return array_keys($affectedSet);
}
/**
* @param array<int, string> $changedFiles
* @return array{0: list<string>, 1: list<string>}
*/
private function partitionChangedPaths(array $changedFiles): array
{
$migrations = [];
$nonMigrations = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel !== null) { if ($rel === null) {
$normalised[] = $rel; continue;
} }
}
$affectedSet = [];
// Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
// every migration, so any migration change would re-run the whole DB suite. Route them via
// table-intersection instead; unparseable migrations fall through to the watch pattern.
$migrationPaths = [];
$nonMigrationPaths = [];
foreach ($normalised as $rel) {
if ($this->isMigrationPath($rel)) { if ($this->isMigrationPath($rel)) {
$migrationPaths[] = $rel; $migrations[] = $rel;
} else { } else {
$nonMigrationPaths[] = $rel; $nonMigrations[] = $rel;
} }
} }
return [$migrations, $nonMigrations];
}
/**
* @param list<string> $migrationPaths
* @param array<string, true> $affectedSet
* @return list<string> Unparseable migrations (caller treats as unknown-to-graph).
*/
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
{
$changedTables = []; $changedTables = [];
$unparseableMigrations = []; $unparseable = [];
foreach ($migrationPaths as $rel) { foreach ($migrationPaths as $rel) {
$tables = $this->tablesForMigration($rel); $tables = $this->tablesForMigration($rel);
if ($tables === []) { if ($tables === []) {
$unparseableMigrations[] = $rel; $unparseable[] = $rel;
continue; continue;
} }
@ -144,8 +182,17 @@ final class Graph
} }
} }
// Inertia page routing: map changed page files to component names and intersect with recorded return $unparseable;
// component edges. Pages with no captured edges fall through to the watch pattern. }
/**
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
* globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved
*/
private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$globalFrontendRuntimeFiles = []; $globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
@ -176,9 +223,8 @@ final class Graph
} }
} }
// Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
// Files absent from the map fall through to the watch pattern.
$sharedFilesResolved = []; $sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
continue; continue;
@ -186,12 +232,12 @@ final class Graph
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
continue; continue;
} }
if (! isset($this->jsFileToComponents[$rel])) { if (! isset($this->jsFileToComponents[$rel])) {
continue; continue;
} }
$touchedAny = false; $touchedAny = false;
foreach ($this->jsFileToComponents[$rel] as $pageComponent) { foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true; $changedComponents[$pageComponent] = true;
@ -204,10 +250,8 @@ final class Graph
} }
} }
// New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
// import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
// trustworthy enough to honour a negative (PHP parser can miss custom aliases).
$newJsFiles = []; $newJsFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
continue; continue;
@ -228,42 +272,7 @@ final class Graph
} }
if ($newJsFiles !== []) { if ($newJsFiles !== []) {
$freshMap = JsModuleGraph::buildStrict($this->projectRoot); $this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
if ($freshMap === null) {
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
// knows precision was downgraded rather than leaving the slower replay unexplained.
$output = Container::getInstance()->get(OutputInterface::class);
if ($output instanceof OutputInterface) {
$output->writeln(sprintf(
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
));
}
} else {
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
// Vite confirms no page imports this file — suppress the watch broadcast.
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
} }
if ($changedComponents !== []) { if ($changedComponents !== []) {
@ -282,8 +291,61 @@ final class Graph
} }
} }
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved];
// their always-on edges from re-running the whole DB suite. }
/**
* @param list<string> $newJsFiles
* @param array<string, true> $changedComponents
* @param array<string, true> $sharedFilesResolved
*/
private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void
{
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) {
View::render('components.badge', [
'type' => 'WARN',
'content' => sprintf(
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
),
]);
return;
}
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
/**
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true> Unknown source dirs (sibling-heuristic).
*/
private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$changedIds = []; $changedIds = [];
$unknownSourceDirs = []; $unknownSourceDirs = [];
$sourcePhpChanged = false; $sourcePhpChanged = false;
@ -300,10 +362,7 @@ final class Graph
} }
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) { if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
$absolute = $this->projectRoot.'/'.$rel; if (! is_file($this->projectRoot.'/'.$rel)) {
if (! is_file($absolute)) {
// Deleted source file unknown to the graph — no edge ever pointed to it.
continue; continue;
} }
@ -313,8 +372,6 @@ final class Graph
} }
} }
// Arch tests inspect structure by namespace/path, never producing coverage edges for the files
// they examine — so a new class can fail an arch expectation without any edge to it.
if ($sourcePhpChanged) { if ($sourcePhpChanged) {
foreach (array_keys($this->edges) as $testFile) { foreach (array_keys($this->edges) as $testFile) {
if ($this->isArchTestFile($testFile)) { if ($this->isArchTestFile($testFile)) {
@ -337,9 +394,45 @@ final class Graph
} }
} }
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered return $unknownSourceDirs;
// ancestors and invalidate only tests that covered them. }
$staticallyHandledBlade = [];
/**
* A changed file inside the configured test suites is itself the unit of
* work — always run it (new untracked tests, edited tests, renames).
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
*/
private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void
{
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
foreach ($nonMigrationPaths as $rel) {
if (isset($affectedSet[$rel])) {
continue;
}
if (! $testPaths->isTestFile($rel)) {
continue;
}
if (! is_file($this->projectRoot.'/'.$rel)) {
continue;
}
$affectedSet[$rel] = true;
}
}
/**
* Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered.
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true>
*/
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$staticallyHandled = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) { if (isset($this->fileIds[$rel])) {
continue; continue;
@ -358,16 +451,33 @@ final class Graph
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
$staticallyHandledBlade[$rel] = true; $staticallyHandled[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) { } elseif ($this->isBladeComponentPath($rel)) {
// Anonymous component with no static usages — treat as orphan rather than broadcasting. $staticallyHandled[$rel] = true;
$staticallyHandledBlade[$rel] = true;
} }
} }
// Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded return $staticallyHandled;
// to avoid re-broadcasting via the watch pattern and defeating the surgical match. }
/**
* @param list<string> $nonMigrationPaths
* @param list<string> $unparseableMigrations
* @param array<string, true> $preciselyHandledPages
* @param array<string, true> $sharedFilesResolved
* @param array<string, true> $staticallyHandledBlade
* @param array<string, true> $affectedSet
*/
private function applyWatchPatternFallback(
array $nonMigrationPaths,
array $unparseableMigrations,
array $preciselyHandledPages,
array $sharedFilesResolved,
array $staticallyHandledBlade,
array &$affectedSet,
): void {
$unknownToGraph = $unparseableMigrations; $unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
continue; continue;
@ -380,7 +490,6 @@ final class Graph
} }
if (! isset($this->fileIds[$rel])) { if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) { if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever pointed to it.
continue; continue;
} }
@ -397,33 +506,37 @@ final class Graph
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
}
// Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from /**
// another branch. Run tests that cover neighbouring files in the same directory so framework- * @param array<string, true> $unknownSourceDirs
// discovered files (Listeners, Events, Policies, etc.) aren't silently missed. * @param array<string, true> $affectedSet
if ($unknownSourceDirs !== []) { */
foreach ($this->edges as $testFile => $ids) { private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void
if (isset($affectedSet[$testFile])) { {
if ($unknownSourceDirs === []) {
return;
}
foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) {
continue;
}
foreach ($ids as $id) {
if (! isset($this->files[$id])) {
continue; continue;
} }
foreach ($ids as $id) { $depDir = dirname($this->files[$id]);
if (! isset($this->files[$id])) {
continue;
}
$depDir = dirname($this->files[$id]); if (isset($unknownSourceDirs[$depDir])) {
$affectedSet[$testFile] = true;
if (isset($unknownSourceDirs[$depDir])) { break;
$affectedSet[$testFile] = true;
break;
}
} }
} }
} }
return array_keys($affectedSet);
} }
public function knowsTest(string $testFile): bool public function knowsTest(string $testFile): bool
@ -471,7 +584,8 @@ final class Graph
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [
$entry = [
'status' => $status, 'status' => $status,
'message' => $message, 'message' => $message,
'time' => $time, 'time' => $time,
@ -482,9 +596,11 @@ final class Graph
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel !== null) { if ($rel !== null) {
$this->baselines[$branch]['results'][$testId]['file'] = $rel; $entry['file'] = $rel;
} }
} }
$this->baselines[$branch]['results'][$testId] = $entry;
} }
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
@ -508,9 +624,6 @@ final class Graph
$r = $baseline['results'][$testId]; $r = $baseline['results'][$testId];
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
// each variant via its specific factory. Keeps the stored message
// intact (important for skips/failures shown to the user).
return match ($r['status']) { return match ($r['status']) {
0 => TestStatus::success(), 0 => TestStatus::success(),
1 => TestStatus::skipped($r['message']), 1 => TestStatus::skipped($r['message']),
@ -528,21 +641,21 @@ final class Graph
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
public function failedOrErroredTestFiles(string $branch, string $fallbackBranch = 'main'): array public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
$files = []; $files = [];
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null; if (! $this->shouldRerun($result['status'])) {
if ($status !== 7 && $status !== 8) {
continue; continue;
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if ($file === null) {
if (! is_string($file) || $file === '') { continue;
}
if ($file === '') {
continue; continue;
} }
@ -556,20 +669,18 @@ final class Graph
return array_keys($files); return array_keys($files);
} }
public function hasUnlocatedFailuresOrErrors(string $branch, string $fallbackBranch = 'main'): bool public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null; if (! $this->shouldRerun($result['status'])) {
if ($status !== 7 && $status !== 8) {
continue; continue;
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if (! is_string($file) || $file === '' || $this->relative($file) === null) { if ($file === null || $file === '' || $this->relative($file) === null) {
return true; return true;
} }
} }
@ -577,6 +688,63 @@ final class Graph
return false; return false;
} }
private function shouldRerun(int $status): bool
{
$testStatus = TestStatus::from($status);
if ($testStatus->isFailure() || $testStatus->isError()) {
return true;
}
$configuration = Registry::get();
if ($testStatus->isRisky()) {
return $configuration->failOnRisky();
}
if ($testStatus->isWarning()) {
if ($configuration->failOnWarning()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerWarnings();
}
if ($testStatus->isNotice()) {
if ($configuration->failOnNotice()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerNotices();
}
if ($testStatus->isDeprecation()) {
if ($configuration->failOnDeprecation()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
}
if ($testStatus->isIncomplete()) {
if ($configuration->failOnIncomplete()) {
return true;
}
return $configuration->displayDetailsOnIncompleteTests();
}
if ($testStatus->isSkipped()) {
if ($configuration->failOnSkipped()) {
return true;
}
return $configuration->displayDetailsOnSkippedTests();
}
return false;
}
/** /**
* @param array<string, string> $tree project-relative path → content hash * @param array<string, string> $tree project-relative path → content hash
*/ */
@ -586,7 +754,6 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree; $this->baselines[$branch]['tree'] = $tree;
} }
// Edges and tree snapshot stay intact; only the run-state is reset.
public function clearResults(string $branch): void public function clearResults(string $branch): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
@ -642,7 +809,6 @@ final class Graph
$this->link($testFile, $source); $this->link($testFile, $source);
} }
// Deduplicate ids for this test.
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel])); $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
} }
} }
@ -703,7 +869,6 @@ final class Graph
} }
} }
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
/** /**
* @param array<string, array<int, string>> $fileToComponents * @param array<string, array<int, string>> $fileToComponents
*/ */
@ -770,7 +935,7 @@ final class Graph
]; ];
foreach ($prefixes as $prefix) { foreach ($prefixes as $prefix) {
if (str_starts_with($rel, $prefix)) { if (str_starts_with($rel, (string) $prefix)) {
return true; return true;
} }
} }
@ -808,7 +973,7 @@ final class Graph
foreach ($repo->getFilenames() as $filename) { foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename); $factory = $repo->get($filename);
if ($factory === null) { if (! $factory instanceof TestCaseFactory) {
continue; continue;
} }
@ -839,24 +1004,14 @@ final class Graph
return $this->archTestFiles; return $this->archTestFiles;
} }
private function methodHasGroup(object $method, string $group): bool private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
{ {
if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) { if (in_array($group, $method->groups, true)) {
return true; return true;
} }
if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) {
return false;
}
foreach ($method->attributes as $attribute) { foreach ($method->attributes as $attribute) {
if (! is_object($attribute)) { if ($attribute->name !== Group::class) {
continue;
}
if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) {
continue;
}
if (! property_exists($attribute, 'arguments')) {
continue; continue;
} }
@ -992,10 +1147,11 @@ final class Graph
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (! $file instanceof \SplFileInfo || ! $file->isFile()) { assert($file instanceof \SplFileInfo);
if (! $file->isFile()) {
continue; continue;
} }
$path = $file->getPathname(); $path = $file->getPathname();
if (! str_ends_with($path, '.blade.php')) { if (! str_ends_with($path, '.blade.php')) {
continue; continue;
@ -1082,7 +1238,6 @@ final class Graph
return TableExtractor::fromMigrationSource($content); return TableExtractor::fromMigrationSource($content);
} }
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
private function componentForInertiaPage(string $rel): ?string private function componentForInertiaPage(string $rel): ?string
{ {
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
@ -1134,13 +1289,7 @@ final class Graph
/** @param array<string, array<int, string>> $edges */ /** @param array<string, array<int, string>> $edges */
private function anyTestUses(array $edges, string $component): bool private function anyTestUses(array $edges, string $component): bool
{ {
foreach ($edges as $components) { return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
if (in_array($component, $components, true)) {
return true;
}
}
return false;
} }
public function pruneMissingTests(): void public function pruneMissingTests(): void
@ -1166,6 +1315,51 @@ final class Graph
} }
} }
/**
* Prune baseline result entries whose test files were just executed but whose
* test IDs are no longer present (e.g. the test method was removed or renamed).
*
* @param array<int, string> $touchedFiles Absolute or project-relative paths.
* @param array<int, string> $keepTestIds Test IDs that produced a result this run.
*/
public function pruneStaleResults(string $branch, array $touchedFiles, array $keepTestIds): void
{
if (! isset($this->baselines[$branch]['results'])) {
return;
}
$touched = [];
foreach ($touchedFiles as $file) {
$rel = $this->relative($file);
if ($rel !== null) {
$touched[$rel] = true;
}
}
if ($touched === []) {
return;
}
$keep = array_fill_keys($keepTestIds, true);
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
$file = $result['file'] ?? null;
if (! is_string($file)) {
continue;
}
if (! isset($touched[$file])) {
continue;
}
if (isset($keep[$testId])) {
continue;
}
unset($this->baselines[$branch]['results'][$testId]);
}
}
public static function decode(string $json, string $projectRoot): ?self public static function decode(string $json, string $projectRoot): ?self
{ {
$data = json_decode($json, true); $data = json_decode($json, true);
@ -1181,78 +1375,51 @@ final class Graph
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : []; $graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
if (isset($data['test_tables']) && is_array($data['test_tables'])) { $graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
foreach ($data['test_tables'] as $testRel => $tables) { $graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
if (! is_string($testRel)) { $graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null);
continue;
}
if (! is_array($tables)) {
continue;
}
$names = [];
foreach ($tables as $table) {
if (is_string($table) && $table !== '') {
$names[] = $table;
}
}
if ($names !== []) {
$graph->testTables[$testRel] = $names;
}
}
}
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
foreach ($data['test_inertia_components'] as $testRel => $components) {
if (! is_string($testRel)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->testInertiaComponents[$testRel] = $names;
}
}
}
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
foreach ($data['js_file_to_components'] as $path => $components) {
if (! is_string($path)) {
continue;
}
if ($path === '') {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->jsFileToComponents[$path] = $names;
}
}
}
return $graph; return $graph;
} }
/**
* @return array<string, list<string>>
*/
private static function decodeStringMap(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$out = [];
foreach ($section as $key => $values) {
if (! is_string($key)) {
continue;
}
if ($key === '') {
continue;
}
if (! is_array($values)) {
continue;
}
$names = [];
foreach ($values as $value) {
if (is_string($value) && $value !== '') {
$names[] = $value;
}
}
if ($names !== []) {
$out[$key] = $names;
}
}
return $out;
}
public function encode(): ?string public function encode(): ?string
{ {
$payload = [ $payload = [
@ -1271,8 +1438,6 @@ final class Graph
return $json === false ? null : $json; return $json === false ? null : $json;
} }
// Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
// Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
private function relative(string $path): ?string private function relative(string $path): ?string
{ {
if ($path === '' || $path === 'unknown') { if ($path === '' || $path === 'unknown') {
@ -1286,10 +1451,13 @@ final class Graph
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive || (strlen($path) >= 2 && $path[1] === ':');
if ($isAbsolute) { if ($isAbsolute) {
$real = @realpath($path); if (array_key_exists($path, $this->realpathCache)) {
$real = $this->realpathCache[$path];
} else {
$real = $this->realpathCache[$path] = @realpath($path);
}
if ($real === false) { if ($real === false) {
$real = $path; $real = $path;
@ -1299,7 +1467,6 @@ final class Graph
return null; return null;
} }
// Always forward slashes — git always uses them; Windows backslashes would never match.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else { } else {
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);

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,108 +8,52 @@ use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
* Builds a reverse dependency map for the project's JS sources under
* `resources/js/**` — for every source file, the list of Inertia page
* components that transitively import it.
*
* Backed by a Node helper (`bin/pest-tia-vite-deps.mjs`) that boots a
* headless Vite server in middleware mode, walks Vite's own module
* graph for each page entry, and outputs JSON. Uses the project's real
* `vite.config.*`, so aliases, plugins, and SFC transformers produce
* the exact graph Vite itself would use.
*
* Two latency mitigations:
*
* 1. **Content-hash cache** keyed by every file under `resources/js/`
* (path + size + mtime) plus the bytes of `vite.config.*` and the
* pages-directory casing. When inputs are unchanged, the 13s+ Node
* bootstrap is skipped entirely and the previous result is reused.
*
* 2. **Background warmer** — `warmInBackground()` is called at suite
* start. It computes the fingerprint, checks the cache, and only
* spawns Node if a refresh is needed. The subprocess runs in
* parallel with the test suite. By the time `build()` is called at
* flush time, the result is usually already on stdout — `wait()`
* returns instantly. If tests finish faster than Vite boots,
* `build()` simply pays the remainder, never the full bootstrap.
*
* Callers invoke `build()` at record time; results are persisted into
* the graph so replay never re-runs the resolver. On stale-map detection
* the callers decide whether to rebuild.
*
* @internal * @internal
*/ */
final class JsModuleGraph 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'; private const string CACHE_FILE = 'js-module-graph.cache.json';
/** Active warmer subprocess, or null when none is in flight. */
private static ?Process $warmer = null;
/** Fingerprint the warmer was started against — used to detect drift between warm and build. */
private static ?string $warmerFingerprint = null;
/** True when the warmer found a fresh cache and skipped spawning Node. */
private static bool $warmerCacheHit = false;
/** Project root the warmer was launched for. */
private static ?string $warmerProjectRoot = null;
/** /**
* Kicks off the Node helper in the background, so by the time * @var list<string>
* `build()` is called at flush time the result is (usually) already
* sitting on stdout. Idempotent — a second call while a warmer is
* already in flight is a no-op. Cheap when the cache is fresh: it
* checks the fingerprint first and skips the subprocess.
*
* Safe to call from any TIA entry point that will eventually write
* the graph from the main process. Workers must NOT call this — they
* don't flush the graph and would duplicate the Node bootstrap on
* every worker.
*/ */
public static function warmInBackground(string $projectRoot): void public const array VITE_CONFIG_NAMES = [
{ 'vite.config.ts',
if (self::$warmer !== null || self::$warmerCacheHit) { 'vite.config.js',
return; 'vite.config.mjs',
} 'vite.config.cjs',
'vite.config.mts',
if (! self::isApplicable($projectRoot)) { ];
return;
}
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null && self::readCache($projectRoot, $fingerprint) !== null) {
self::$warmerCacheHit = true;
self::$warmerFingerprint = $fingerprint;
self::$warmerProjectRoot = $projectRoot;
return;
}
$process = self::buildNodeProcess($projectRoot);
if ($process === null) {
return;
}
try {
$process->start();
} catch (\Throwable) {
return;
}
self::$warmer = $process;
self::$warmerFingerprint = $fingerprint;
self::$warmerProjectRoot = $projectRoot;
register_shutdown_function(self::reapWarmer(...));
}
/** /**
* @return array<string, list<string>> project-relative source path → sorted list of page component names * Candidate page directories, in priority order. Must stay in sync with
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
*
* @var list<string>
*/
private const array PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
];
/**
* @var list<string>
*/
private const array PAGE_EXTENSIONS = [
'vue', 'svelte',
'tsx', 'jsx',
'ts', 'js',
'mts', 'cts', 'mjs', 'cjs',
];
/**
* @return array<string, list<string>>
*/ */
public static function build(string $projectRoot): array public static function build(string $projectRoot): array
{ {
@ -119,13 +63,6 @@ final class JsModuleGraph
} }
/** /**
* Strict variant — returns null when the Node resolver isn't
* available, so callers can distinguish "Vite says nothing imports
* this file" (empty list) from "we couldn't ask Vite" (null).
*
* Used at replay time when we need to *trust a negative result*
* (i.e., "no page imports this file, so it's orphan, safe to skip").
*
* @return array<string, list<string>>|null * @return array<string, list<string>>|null
*/ */
public static function buildStrict(string $projectRoot): ?array public static function buildStrict(string $projectRoot): ?array
@ -133,24 +70,50 @@ final class JsModuleGraph
return self::resolve($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 public static function isApplicable(string $projectRoot): bool
{ {
if (! self::hasViteConfig($projectRoot)) { if (! self::hasViteConfig($projectRoot)) {
return false; return false;
} }
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React return self::firstExistingPagesDir($projectRoot) !== null;
// 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 private static function firstExistingPagesDir(string $projectRoot): ?string
// graph based on which one it picks. {
foreach (['Pages', 'pages'] as $dir) { foreach (self::PAGE_DIR_CANDIDATES as $rel) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) { $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; return true;
} }
} }
@ -169,79 +132,13 @@ final class JsModuleGraph
$cached = self::readCache($projectRoot, $fingerprint); $cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) { if ($cached !== null) {
self::reapWarmer();
return $cached; return $cached;
} }
} }
// Pick up the warmer when it was launched against the same
// fingerprint and project root. Drift between warm and build
// (rare — would require a JS file to change mid-test-run)
// discards the warmer and re-runs synchronously.
if (self::$warmerCacheHit
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot
&& $fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
self::$warmerCacheHit = false;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
if ($cached !== null) {
return $cached;
}
}
if (self::$warmer !== null
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot) {
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
try {
$process->wait();
} catch (\Throwable) {
// fall through to synchronous run
$process = null;
}
if ($process !== null && $process->isSuccessful()) {
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null) {
if ($fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
}
} else {
// Different fingerprint or different project root: discard
// any stale warmer before we start a fresh run.
self::reapWarmer();
}
$viaNode = self::runNodeSync($projectRoot);
if ($viaNode !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $viaNode);
}
return $viaNode;
}
/**
* @return array<string, list<string>>|null
*/
private static function runNodeSync(string $projectRoot): ?array
{
$process = self::buildNodeProcess($projectRoot); $process = self::buildNodeProcess($projectRoot);
if ($process === null) { if (! $process instanceof Process) {
return null; return null;
} }
@ -251,7 +148,13 @@ final class JsModuleGraph
return null; return null;
} }
return self::parseNodeOutput($process->getOutput()); $result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
} }
private static function buildNodeProcess(string $projectRoot): ?Process private static function buildNodeProcess(string $projectRoot): ?Process
@ -276,21 +179,7 @@ final class JsModuleGraph
return null; return null;
} }
// Tell the Node helper which casing this project uses for its $process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
// 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->setTimeout(self::NODE_TIMEOUT_SECONDS); $process->setTimeout(self::NODE_TIMEOUT_SECONDS);
return $process; return $process;
@ -336,56 +225,11 @@ final class JsModuleGraph
return $out; return $out;
} }
/**
* Stop and discard a leftover warmer subprocess (e.g. on shutdown,
* or when `build()` resolved from cache without needing the warmer).
*/
private static function reapWarmer(): void
{
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
self::$warmerCacheHit = false;
if ($process === null) {
return;
}
try {
if ($process->isRunning()) {
$process->stop(0.5);
}
} catch (\Throwable) {
// best-effort cleanup
}
}
/**
* Content fingerprint of every input that can change the Node/Vite
* module graph: each `resources/js/**` source (path + size + mtime),
* each `vite.config.*` (path + size + mtime + sha-of-bytes), and
* the chosen pages-directory casing. Returns null only when no
* `vite.config.*` exists — i.e. the resolver itself wouldn't run.
*
* File inputs use `mtime+size` rather than full content hashes —
* walking thousands of SFCs and re-hashing them on every flush
* would defeat the point of the cache. mtime/size collisions on
* an edited file are theoretically possible but vanishingly rare,
* and the cost of a rare miss (one extra Node run) is exactly what
* the cache costs anyway. The vite config itself is small and
* load-bearing for plugin/alias behaviour, so we hash its bytes
* outright.
*/
private static function fingerprint(string $projectRoot): ?string private static function fingerprint(string $projectRoot): ?string
{ {
if (! self::hasViteConfig($projectRoot)) {
return null;
}
$parts = []; $parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name; $path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) { if (! is_file($path)) {
@ -401,17 +245,25 @@ final class JsModuleGraph
.':'.($bytes === false ? '' : hash('sha256', $bytes)); .':'.($bytes === false ? '' : hash('sha256', $bytes));
} }
foreach (['Pages', 'pages'] as $dir) { if ($parts === []) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) { return null;
$parts[] = 'pagesDir:'.$dir;
break;
}
} }
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'; $override = getenv('TIA_VITE_PAGES_DIR');
if (is_dir($jsRoot)) { if (is_string($override) && $override !== '') {
$parts[] = 'pagesDirOverride:'.$override;
}
$pagesDir = self::firstExistingPagesDir($projectRoot);
if ($pagesDir !== null) {
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
}
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = []; $entries = [];
$iterator = new \RecursiveIteratorIterator( $iterator = new \RecursiveIteratorIterator(
@ -475,10 +327,12 @@ final class JsModuleGraph
$out = []; $out = [];
foreach ($graph as $key => $value) { foreach ($graph as $key => $value) {
if (! is_string($key) || ! is_array($value)) { if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue; continue;
} }
$names = []; $names = [];
foreach ($value as $name) { foreach ($value as $name) {
@ -494,7 +348,7 @@ final class JsModuleGraph
} }
/** /**
* @param array<string, list<string>> $graph * @param array<string, list<string>> $graph
*/ */
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{ {
@ -532,12 +386,6 @@ final class JsModuleGraph
private static function hasViteConfig(string $projectRoot): bool private static function hasViteConfig(string $projectRoot): bool
{ {
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
}
}
return false;
} }
} }

View File

@ -8,9 +8,6 @@ use Pest\TestSuite;
use ReflectionClass; use ReflectionClass;
/** /**
* Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state
* shared across the `Prepared` and `Finished` subscribers.
*
* @internal * @internal
*/ */
final class Recorder final class Recorder
@ -35,24 +32,6 @@ final class Recorder
/** @var array<string, bool> */ /** @var array<string, bool> */
private array $classUsesDatabaseCache = []; private array $classUsesDatabaseCache = [];
// Source file → declared class names. Built incrementally as classes are autoloaded.
// Used to walk the interface/trait/parent hierarchy which coverage drivers miss
// (interfaces and empty traits emit no executable bytecode).
/** @var array<string, list<string>> */
private array $fileToClassNames = [];
/** @var array<string, true> */
private array $indexedClassNames = [];
/** @var array<string, list<string>> */
private array $classDependencyCache = [];
/** @var array<string, list<string>> */
private array $testImportFileCache = [];
/** @var array<string, true> */
private array $includedFilesAtTestStart = [];
private bool $active = false; private bool $active = false;
private bool $driverChecked = false; private bool $driverChecked = false;
@ -61,6 +40,8 @@ final class Recorder
private string $driver = 'none'; private string $driver = 'none';
private ?SourceScope $sourceScope = null;
public function activate(): void public function activate(): void
{ {
$this->active = true; $this->active = true;
@ -78,8 +59,6 @@ final class Recorder
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents
// (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent.
$modes = \xdebug_info('mode'); $modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) { if (is_array($modes) && in_array('coverage', $modes, true)) {
@ -94,13 +73,6 @@ final class Recorder
return $this->driverAvailable; return $this->driverAvailable;
} }
public function driver(): string
{
$this->driverAvailable();
return $this->driver;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void public function beginTest(string $className, string $methodName, string $fallbackFile): void
{ {
if (! $this->active || ! $this->driverAvailable()) { if (! $this->active || ! $this->driverAvailable()) {
@ -118,17 +90,11 @@ final class Recorder
} }
$this->currentTestFile = $file; $this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) { if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
} }
// Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only
// TestCase) emit no executable bytecode, so the coverage driver never records them.
$this->linkAncestorFiles($className);
$this->linkImportedFiles($file);
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\clear(); \pcov\clear();
\pcov\start(); \pcov\start();
@ -136,7 +102,6 @@ final class Recorder
return; return;
} }
// Xdebug
\xdebug_start_code_coverage(); \xdebug_start_code_coverage();
} }
@ -148,24 +113,23 @@ final class Recorder
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\stop(); \pcov\stop();
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\all);
// pcov returns every executable line in every file it $scope = $this->sourceScope();
// tracked: positive values for executed lines, `-1` for $filesToCollectCoverageFor = [];
// executable-but-not-run. A file with no positives was
// loaded but nothing in it ran during this test's window foreach (\pcov\waiting() as $file) {
// — typically a declaration-only file (Mailables, Enums, if (is_string($file) && $scope->contains($file)) {
// DTOs) pulled in by some service-provider's static `use` $filesToCollectCoverageFor[] = $file;
// at framework boot. Including those attributes every }
// globally-bootstrapped class to whichever test triggered }
// the boot, blowing up the affected set on edits to those
// files. /** @var array<string, mixed> $data */
$coveredFiles = self::filesWithExecutedLines($data); $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = $this->filesWithExecutedLines($data);
} else { } else {
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage(); $data = \xdebug_get_code_coverage();
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
\xdebug_stop_code_coverage(true); \xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data); $coveredFiles = array_keys($data);
@ -175,21 +139,7 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
foreach (AutoloadEdges::newProjectFiles(
$this->includedFilesAtTestStart,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
$this->currentTestFile,
) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
// so a signature change would leave implementing-class tests stale without this walk.
$this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null; $this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
} }
public function linkSource(string $sourceFile): void public function linkSource(string $sourceFile): void
@ -209,294 +159,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
/** @param iterable<int, string> $sourceFiles */
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
return;
}
if ($testFile === '') {
return;
}
foreach ($sourceFiles as $sourceFile) {
if ($sourceFile === '') {
continue;
}
$this->perTestFiles[$testFile][$sourceFile] = true;
}
}
/** @param array<int, string> $coveredFiles */
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
return;
}
$this->refreshClassMap();
foreach ($coveredFiles as $coveredFile) {
if (! isset($this->fileToClassNames[$coveredFile])) {
continue;
}
foreach ($this->fileToClassNames[$coveredFile] as $name) {
foreach ($this->classDependencies($name) as $depFile) {
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
}
}
}
}
private function refreshClassMap(): void
{
$names = array_merge(
get_declared_classes(),
get_declared_interfaces(),
get_declared_traits(),
);
foreach ($names as $name) {
if (isset($this->indexedClassNames[$name])) {
continue;
}
$this->indexedClassNames[$name] = true;
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
continue;
}
$reflection = new ReflectionClass($name);
if ($reflection->isInternal()) {
continue;
}
$file = $reflection->getFileName();
if (! is_string($file)) {
continue;
}
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
continue;
}
$this->fileToClassNames[$file][] = $name;
}
}
/** @return list<string> */
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
return $this->classDependencyCache[$className];
}
if (! class_exists($className, false)
&& ! interface_exists($className, false)
&& ! trait_exists($className, false)) {
return $this->classDependencyCache[$className] = [];
}
$reflection = new ReflectionClass($className);
$files = [];
$linkSymbol = static function (string $name) use (&$files): void {
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
return;
}
$r = new ReflectionClass($name);
if ($r->isInternal()) {
return;
}
$f = $r->getFileName();
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
return;
}
$files[$f] = true;
};
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $reflection->getParentClass();
while ($parent !== false && ! $parent->isInternal()) {
$f = $parent->getFileName();
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$f] = true;
}
foreach ($parent->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $parent->getParentClass();
}
return $this->classDependencyCache[$className] = array_keys($files);
}
private function linkAncestorFiles(string $className): void
{
if (! class_exists($className, false)) {
return;
}
$reflection = new ReflectionClass($className);
$parent = $reflection->getParentClass();
while ($parent !== false) {
if ($parent->isInternal()) {
break;
}
$file = $parent->getFileName();
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
}
$parent = $parent->getParentClass();
}
}
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
return;
}
foreach ($this->importedFilesFor($testFile) as $file) {
$this->perTestFiles[$this->currentTestFile][$file] = true;
}
}
/**
* @return list<string>
*/
private function importedFilesFor(string $testFile): array
{
if (array_key_exists($testFile, $this->testImportFileCache)) {
return $this->testImportFileCache[$testFile];
}
$source = @file_get_contents($testFile);
if ($source === false) {
return $this->testImportFileCache[$testFile] = [];
}
$files = [];
foreach ($this->importedClassNames($source) as $className) {
$file = $this->findAutoloadFile($className);
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$file] = true;
}
}
return $this->testImportFileCache[$testFile] = array_keys($files);
}
/**
* @return list<string>
*/
private function importedClassNames(string $source): array
{
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
$classes = [];
foreach ($matches[1] as $import) {
$import = trim($import);
if ($import === '') {
continue;
}
$open = strpos($import, '{');
$close = strrpos($import, '}');
if ($open !== false && $close !== false && $close > $open) {
$prefix = trim(trim(substr($import, 0, $open)), '\\');
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
foreach ($items as $item) {
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
if ($class !== null) {
$classes[$class] = true;
}
}
continue;
}
$class = $this->normaliseImportedClass($import);
if ($class !== null) {
$classes[$class] = true;
}
}
return array_keys($classes);
}
private function normaliseImportedClass(string $import): ?string
{
$import = trim(trim($import), '\\');
if ($import === '') {
return null;
}
$parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false || $parts === []) {
return null;
}
$class = trim(trim($parts[0]), '\\');
return $class === '' ? null : $class;
}
private function findAutoloadFile(string $className): ?string
{
foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) {
continue;
}
if (! method_exists($loader[0], 'findFile')) {
continue;
}
/** @var mixed $file */
$file = $loader[0]->findFile($className);
if (is_string($file) && $file !== '') {
$real = @realpath($file);
return $real === false ? $file : $real;
}
}
return null;
}
private function classUsesDatabase(string $className): bool private function classUsesDatabase(string $className): bool
{ {
if (array_key_exists($className, $this->classUsesDatabaseCache)) { if (array_key_exists($className, $this->classUsesDatabaseCache)) {
@ -628,64 +290,56 @@ final class Recorder
return null; return null;
} }
// Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName()
// (which returns the trait file for methods brought in via `uses SharedTestBehavior`).
private function readPestFilename(string $className): ?string private function readPestFilename(string $className): ?string
{ {
if (! class_exists($className, false)) { if (! class_exists($className, false)) {
return null; return null;
} }
$reflection = new ReflectionClass($className); assert(property_exists($className, '__filename') && is_string($className::$__filename));
if ($reflection->hasProperty('__filename')) { return $className::$__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;
} }
/** /**
* Filters pcov's `file => line => executionCount` map to the files
* that actually had at least one executed line. pcov reports `-1`
* for "executable but not run" and a positive count for executed
* lines; a file with no positives was loaded but contributed no
* executed code to this test.
*
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return list<string> * @return list<string>
*/ */
private static function filesWithExecutedLines(array $data): array private function filesWithExecutedLines(array $data): array
{ {
$out = []; $out = [];
foreach ($data as $file => $lines) { foreach ($data as $file => $lines) {
if (! is_string($file) || ! is_array($lines)) { if (! is_array($lines)) {
continue;
}
$covered = [];
foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) {
$covered[] = $line;
}
}
if ($covered === []) {
continue; continue;
} }
foreach ($lines as $count) { $lineKeys = array_keys($lines);
if (is_int($count) && $count > 0) { if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
$out[] = $file; continue;
continue 2;
}
} }
$out[] = $file;
} }
return $out; return $out;
} }
private function sourceScope(): SourceScope
{
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
}
public function reset(): void public function reset(): void
{ {
$this->currentTestFile = null; $this->currentTestFile = null;
@ -695,9 +349,7 @@ final class Recorder
$this->perTestUsesDatabase = []; $this->perTestUsesDatabase = [];
$this->classFileCache = []; $this->classFileCache = [];
$this->classUsesDatabaseCache = []; $this->classUsesDatabaseCache = [];
$this->fileToClassNames = []; $this->sourceScope = null;
$this->indexedClassNames = [];
$this->classDependencyCache = [];
$this->active = false; $this->active = false;
} }
} }

View File

@ -4,11 +4,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; 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 * @internal
*/ */
final class ResultCollector final class ResultCollector
@ -37,7 +35,7 @@ final class ResultCollector
return; return;
} }
$this->record(0, ''); $this->record(TestStatus::success());
} }
public function testFailed(string $message): void public function testFailed(string $message): void
@ -46,7 +44,7 @@ final class ResultCollector
return; return;
} }
$this->record(7, $message); $this->record(TestStatus::failure($message));
} }
public function testErrored(string $message): void public function testErrored(string $message): void
@ -55,7 +53,7 @@ final class ResultCollector
return; return;
} }
$this->record(8, $message); $this->record(TestStatus::error($message));
} }
public function testSkipped(string $message): void public function testSkipped(string $message): void
@ -64,7 +62,7 @@ final class ResultCollector
return; return;
} }
$this->record(1, $message); $this->record(TestStatus::skipped($message));
} }
public function testIncomplete(string $message): void public function testIncomplete(string $message): void
@ -73,7 +71,7 @@ final class ResultCollector
return; return;
} }
$this->record(2, $message); $this->record(TestStatus::incomplete($message));
} }
public function testRisky(string $message): void public function testRisky(string $message): void
@ -82,7 +80,7 @@ final class ResultCollector
return; return;
} }
$this->record(5, $message); $this->record(TestStatus::risky($message));
} }
/** /**
@ -101,10 +99,6 @@ final class ResultCollector
} }
/** /**
* Injects externally-collected results (e.g. partials flushed by parallel
* workers) into this collector so the parent can persist them in the same
* snapshot pass as non-parallel runs.
*
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results * @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/ */
public function merge(array $results): void public function merge(array $results): void
@ -122,11 +116,6 @@ final class ResultCollector
$this->startTime = 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 public function finishTest(): void
{ {
$this->currentTestId = null; $this->currentTestId = null;
@ -134,7 +123,7 @@ final class ResultCollector
$this->startTime = null; $this->startTime = null;
} }
private function record(int $status, string $message): void private function record(TestStatus $status): void
{ {
if ($this->currentTestId === null) { if ($this->currentTestId === null) {
return; return;
@ -144,15 +133,11 @@ final class ResultCollector
? round(microtime(true) - $this->startTime, 3) ? round(microtime(true) - $this->startTime, 3)
: 0.0; : 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; $existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [ $this->results[$this->currentTestId] = [
'status' => $status, 'status' => $status->asInt(),
'message' => $message, 'message' => $status->message(),
'time' => $time, 'time' => $time,
'assertions' => $existing['assertions'] ?? 0, 'assertions' => $existing['assertions'] ?? 0,
]; ];

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; 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 * @internal
*/ */
final class Storage final class Storage
{ {
/**
* Directory where TIA's State blobs live for `$projectRoot`.
*/
public static function tempDir(string $projectRoot): string public static function tempDir(string $projectRoot): string
{ {
$home = self::homeDir(); $home = self::homeDir();
@ -51,15 +25,6 @@ final class Storage
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot); .DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
} }
/**
* Wipes the on-disk state directory for `$projectRoot`. Called by
* `--fresh` so a rebuild starts from a truly empty cache: no stale
* baseline, no leftover worker partials, no fingerprint, no JS
* module cache. Subsequent writes recreate the directory on demand.
*
* Per-project (project key is part of the path) — sibling projects'
* caches under `~/.pest/tia/` are untouched.
*/
public static function purge(string $projectRoot): void public static function purge(string $projectRoot): void
{ {
$dir = self::tempDir($projectRoot); $dir = self::tempDir($projectRoot);
@ -80,10 +45,12 @@ final class Storage
} }
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
$path = $dir.DIRECTORY_SEPARATOR.$entry; $path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) { if (is_dir($path) && ! is_link($path)) {
@ -98,11 +65,6 @@ final class Storage
@rmdir($dir); @rmdir($dir);
} }
/**
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
* Windows. Returns null if neither resolves to an existing
* directory, in which case callers fall back to project-local state.
*/
private static function homeDir(): ?string private static function homeDir(): ?string
{ {
foreach (['HOME', 'USERPROFILE'] as $key) { foreach (['HOME', 'USERPROFILE'] as $key) {
@ -117,27 +79,7 @@ final class Storage
} }
/** /**
* Folder name for `$projectRoot` under `~/.pest/tia/`.
*
* Strategy — each step rules out a class of collision:
*
* 1. If the project has a git origin URL, use a **normalised** form
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar` * `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 private static function projectKey(string $projectRoot): string
{ {
@ -152,12 +94,6 @@ final class Storage
return $slug === '' ? $hash : $slug.'-'.$hash; 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 private static function originIdentity(string $projectRoot): ?string
{ {
$url = self::rawOriginUrl($projectRoot); $url = self::rawOriginUrl($projectRoot);
@ -176,8 +112,6 @@ final class Storage
return strtolower($m[1].'/'.$m[2]); 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); return strtolower($url);
} }
@ -202,11 +136,6 @@ final class Storage
return null; 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 private static function slug(string $name): string
{ {
$slug = strtolower($name); $slug = strtolower($name);

View File

@ -5,46 +5,14 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; 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 * @internal
*/ */
final class TableExtractor 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']; private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
/** /**
* @return list<string> Sorted, deduped table names referenced by the * @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 public static function fromSql(string $sql): array
{ {
@ -55,23 +23,12 @@ final class TableExtractor
} }
$prefix = strtolower(substr($trimmed, 0, 6)); $prefix = strtolower(substr($trimmed, 0, 6));
$matched = array_any(self::DML_PREFIXES, fn (string $dml): bool => str_starts_with($prefix, $dml));
$matched = false;
foreach (self::DML_PREFIXES as $dml) {
if (str_starts_with($prefix, $dml)) {
$matched = true;
break;
}
}
if (! $matched) { if (! $matched) {
return []; 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'; $pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
if (preg_match_all($pattern, $sql, $matches) === false) { if (preg_match_all($pattern, $sql, $matches) === false) {
@ -106,35 +63,11 @@ final class TableExtractor
/** /**
* @return list<string> Table names referenced by `Schema::` calls, * @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 public static function fromMigrationSource(string $php): array
{ {
$tables = []; $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*[\'"]([^\'"]+)[\'"])?/'; $schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) { 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'; $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) { 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 = [ $dmlPatterns = [
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i', '/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i', '/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
@ -196,11 +117,6 @@ final class TableExtractor
return $out; 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 private static function isSchemaMeta(string $name): bool
{ {
$lower = strtolower($name); $lower = strtolower($name);

View File

@ -5,38 +5,12 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; 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 * @internal
*/ */
final class TableTracker final class TableTracker
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; 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'; private const string MARKER = 'pest.tia.table-tracker-armed';
public static function arm(Recorder $recorder): void 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 */ /** @var object $db */
$db = $app->make('db'); $db = $app->make('db');
@ -102,11 +70,6 @@ final class TableTracker
return; 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')) { if (! $app->bound('events')) {
return; return;
} }
@ -118,11 +81,6 @@ final class TableTracker
return; 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); $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 Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier; use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory; use Pest\Factories\TestCaseFactory;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite; 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 * @internal
*/ */
final readonly class Browser implements WatchDefault final readonly class Browser implements WatchDefault
{ {
public function applicable(): bool 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) return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser'); && InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
} }
@ -33,33 +26,10 @@ final readonly class Browser implements WatchDefault
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath); $browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [ $globs = [
'resources/js/**/*.js', 'resources/js/** !*.php',
'resources/js/**/*.ts', 'resources/css/** !*.php',
'resources/js/**/*.tsx', 'public/hot !*.php',
'resources/js/**/*.jsx', 'public/** !*.php',
'resources/js/**/*.vue',
'resources/js/**/*.svelte',
'resources/css/**/*.css',
'resources/css/**/*.scss',
'resources/css/**/*.less',
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js',
'public/build/**/*.css',
// Static public assets can affect browser-rendered pages without
// any PHP file changing (favicons, robots, images, downloaded
// manifests, etc.). Only browser-test targets are invalidated.
'public/**/*.js',
'public/**/*.css',
'public/**/*.svg',
'public/**/*.png',
'public/**/*.jpg',
'public/**/*.jpeg',
'public/**/*.webp',
'public/**/*.ico',
'public/**/*.txt',
'public/**/*.json',
'public/**/*.xml',
'public/hot',
]; ];
$patterns = []; $patterns = [];
@ -84,9 +54,6 @@ final readonly class Browser implements WatchDefault
$targets[] = $candidate; $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)) { if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests; $repo = TestSuite::getInstance()->tests;

View File

@ -5,14 +5,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; 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 * @internal
*/ */
final readonly class Inertia implements WatchDefault final readonly class Inertia implements WatchDefault
@ -26,41 +21,8 @@ final readonly class Inertia implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath); return [
'resources/js/** !*.php' => [$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;
} }
} }

View File

@ -5,16 +5,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; 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 * @internal
*/ */
final readonly class Laravel implements WatchDefault final readonly class Laravel implements WatchDefault
@ -28,74 +21,21 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ 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], 'database/migrations/**/*.php' => [$testPath],
// Seeders — often run globally via Pest.php beforeEach.
'database/seeders/**/*.php' => [$testPath],
// Factories — loaded lazily but still PHP that coverage may miss
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath],
// Project fixture data. Laravel apps often keep fake repository
// lockfiles / API payloads here and read them via `storage_path()`
// + `file_get_contents()`, which neither PHP coverage nor static
// import edges can observe.
'storage/fixtures/**/*' => [$testPath], 'storage/fixtures/**/*' => [$testPath],
// Non-PHP templates/data living beside app code. These are often 'app/** !*.php' => [$testPath],
// read dynamically by services (Dockerfile templates, stubs,
// payload examples) and never appear in coverage because PHP only
// sees the reader method, not the external file.
'app/**/*.tpl' => [$testPath],
'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath],
'app/**/*.yaml' => [$testPath],
'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath],
// Blade templates — compiled to cache, source file not executed. 'resources/views/**' => [$testPath],
'resources/views/**/*.blade.php' => [$testPath],
// Mail / view-adjacent themes can be read dynamically by
// mailables (for example Laravel's markdown mail theme CSS).
'resources/views/**/*.css' => [$testPath],
// Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$testPath],
// Translations — JSON translations read via file_get_contents, 'lang/**' => [$testPath],
// PHP translations loaded via include (but during boot). 'resources/lang/**' => [$testPath],
'lang/**/*.php' => [$testPath],
'lang/**/*.json' => [$testPath],
'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$testPath],
// Build tool config — affects compiled assets consumed by 'vite.config.* !*.php' => [$testPath],
// browser and Inertia tests. 'webpack.mix.* !*.php' => [$testPath],
'vite.config.js' => [$testPath], 'tailwind.config.* !*.php' => [$testPath],
'vite.config.ts' => [$testPath], 'postcss.config.* !*.php' => [$testPath],
'webpack.mix.js' => [$testPath],
'tailwind.config.js' => [$testPath],
'tailwind.config.ts' => [$testPath],
'postcss.config.js' => [$testPath],
]; ];
} }
} }

View File

@ -5,14 +5,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; 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 * @internal
*/ */
final readonly class Livewire implements WatchDefault final readonly class Livewire implements WatchDefault
@ -26,15 +21,10 @@ final readonly class Livewire implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
// Livewire views live alongside Blade views or in a dedicated dir.
'resources/views/livewire/**/*.blade.php' => [$testPath], 'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.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], 'resources/views/pages/**/*.blade.php' => [$testPath],
// Livewire JS interop / Alpine plugins.
'resources/js/**/*.js' => [$testPath], 'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath], 'resources/js/**/*.ts' => [$testPath],
]; ];

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/** /**
* Baseline watch patterns for any PHP project.
*
* @internal * @internal
*/ */
final readonly class Php implements WatchDefault final readonly class Php implements WatchDefault
@ -18,55 +18,20 @@ final readonly class Php implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array 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 [ 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' => [$testPath],
'.env.testing' => [$testPath], '.env.testing' => [$testPath],
'.env.local' => [$testPath], '.env.local' => [$testPath],
'.env.*.local' => [$testPath], '.env.*.local' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath], 'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath], 'docker-compose.yaml' => [$testPath],
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but 'phpunit.xml*' => [$testPath],
// phpunit.xml.dist and other XML overrides are not individually
// tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath],
// `tests/Pest.php` is loaded once per suite (during BootFiles)
// so its `pest()->extend()`, `expect()->extend()`, helpers,
// etc. execute outside the per-test coverage window — no
// edge captures it. Watch-pattern broadcast triggers a
// replay of every test (results refresh) without a full
// record-mode graph rebuild.
$testPath.'/Pest.php' => [$testPath],
// Pest dataset definitions are loaded once at boot, outside
// the per-test coverage window — no edge captures them. A
// change to a shared dataset can flip the result of any test
// that uses it, so broadcast every dataset edit to the full
// suite.
$testPath.'/Datasets/**/*.php' => [$testPath],
// Test fixtures — data/source snippets consumed by assertions or
// external analysers. Nested `Fixtures/` directories are common
// beside a single test class, and PHP fixtures may be parsed by
// tools without being `require`d, so coverage cannot see them.
$testPath.'/Fixtures/**/*' => [$testPath], $testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath], $testPath.'/**/Fixtures/**/*' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath], $testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
]; ];
} }

View File

@ -5,10 +5,9 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/** /**
* Watch patterns for Symfony projects.
*
* @internal * @internal
*/ */
final readonly class Symfony implements WatchDefault final readonly class Symfony implements WatchDefault
@ -21,59 +20,23 @@ final readonly class Symfony implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array 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 [ return [
// Config — YAML, XML, and PHP. All loaded during kernel boot. 'config/** !*.php' => [$testPath],
'config/*.yaml' => [$testPath], 'config/routes/** !*.php' => [$testPath],
'config/*.yml' => [$testPath],
'config/*.php' => [$testPath],
'config/*.xml' => [$testPath],
'config/**/*.yaml' => [$testPath],
'config/**/*.yml' => [$testPath],
'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$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], 'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath], 'src/Migrations/**/*.php' => [$testPath],
// Twig templates — compiled, source not PHP-executed. 'templates/** !*.php' => [$testPath],
'templates/**/*.html.twig' => [$testPath],
'templates/**/*.twig' => [$testPath],
// Translations (YAML / XLF / XLIFF). 'translations/** !*.php' => [$testPath],
'translations/**/*.yaml' => [$testPath],
'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath],
// Doctrine XML/YAML mappings.
'config/doctrine/**/*.xml' => [$testPath], 'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath], 'config/doctrine/**/*.yaml' => [$testPath],
// Webpack Encore / asset-mapper config + frontend sources.
'webpack.config.js' => [$testPath], 'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath], 'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath], 'assets/** !*.php' => [$testPath],
'assets/**/*.ts' => [$testPath],
'assets/**/*.vue' => [$testPath],
'assets/**/*.css' => [$testPath],
'assets/**/*.scss' => [$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; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\WatchDefaults\WatchDefault; use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite; 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 * @internal
*/ */
final class WatchPatterns final class WatchPatterns
{ {
/** /**
* All known default providers, in evaluation order.
*
* @var array<int, class-string<WatchDefault>> * @var array<int, class-string<WatchDefault>>
*/ */
private const array DEFAULTS = [ private const array DEFAULTS = [
@ -38,23 +24,26 @@ final class WatchPatterns
WatchDefaults\Browser::class, 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 = []; private array $patterns = [];
/**
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
*/
private array $parsed = [];
private bool $enabled = false; private bool $enabled = false;
private bool $locally = false; private bool $locally = false;
private bool $filtered = false; private bool $filtered = false;
/** private bool $baselined = false;
* Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
* calls are already in `$this->patterns`).
*/
public function useDefaults(string $projectRoot): void public function useDefaults(string $projectRoot): void
{ {
$testPath = TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
@ -66,33 +55,27 @@ final class WatchPatterns
continue; continue;
} }
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) { foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
$this->patterns[$glob] = array_values(array_unique( $this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], $dirs), array_merge($this->patterns[$key] ?? [], $dirs),
)); ));
} }
} }
} }
/** /**
* Adds user-defined patterns. Merges with existing entries so a single * @param array<string, string> $patterns pattern key → project-relative test dir/file
* glob can map to multiple directories.
*
* @param array<string, string> $patterns glob → project-relative test dir/file
*/ */
public function add(array $patterns): void public function add(array $patterns): void
{ {
foreach ($patterns as $glob => $dir) { foreach ($patterns as $key => $dir) {
$this->patterns[$glob] = array_values(array_unique( $this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], [$dir]), 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 string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths. * @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files. * @return array<int, string> Project-relative test dirs/files.
@ -106,11 +89,13 @@ final class WatchPatterns
$matched = []; $matched = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {
foreach ($this->patterns as $glob => $dirs) { foreach ($this->patterns as $key => $dirs) {
if ($this->globMatches($glob, $file)) { if (! $this->keyMatches($key, $file)) {
foreach ($dirs as $dir) { continue;
$matched[$dir] = true; }
}
foreach ($dirs as $dir) {
$matched[$dir] = true;
} }
} }
} }
@ -119,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> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph. * @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string> * @return array<int, string>
@ -185,19 +167,116 @@ final class WatchPatterns
return $this->filtered; return $this->filtered;
} }
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void public function reset(): void
{ {
$this->patterns = []; $this->patterns = [];
$this->parsed = [];
$this->enabled = false; $this->enabled = false;
$this->locally = false; $this->locally = false;
$this->filtered = 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. * @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
*
* Supports `*` (single segment), `**` (any depth) and `?`.
*/ */
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 private function globMatches(string $pattern, string $file): bool
{ {
$pattern = str_replace('\\', '/', $pattern); $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; 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 * @internal
*/ */
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber 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(); $this->collector->finishTest();
} }
} }

View File

@ -9,12 +9,9 @@ use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber; 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 * @internal
*/ */
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber final readonly class EnsureTiaEnds implements FinishedSubscriber
{ {
public function __construct(private Recorder $recorder) {} 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,25 +6,17 @@ namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PreparedSubscriber; 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 * @internal
*/ */
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
{ {
public function __construct(private ResultCollector $collector) {} public function __construct(private ResultCollector $collector) {}
public function notify(Prepared $event): void public function notify(PreparationStarted $event): void
{ {
$test = $event->test(); $test = $event->test();

View File

@ -10,13 +10,9 @@ use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber; 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 * @internal
*/ */
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber final readonly class EnsureTiaStarts implements PreparedSubscriber
{ {
public function __construct(private Recorder $recorder) {} public function __construct(private Recorder $recorder) {}

View File

@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Report\Facade;
use SebastianBergmann\Environment\Runtime; use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -89,20 +90,24 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath)); 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); CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */ /** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath; $codeCoverage = require $reportPath;
unlink($reportPath); unlink($reportPath);
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); // @phpstan-ignore-next-line
if (is_array($codeCoverage)) {
$facade = Facade::fromSerializedData($codeCoverage);
/** @var Directory<File|Directory> $report */ /** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport(); $report = (fn (): Directory => $this->report)->call($facade);
} else {
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
}
$totalCoverage = $report->percentageOfExecutedLines();
foreach ($report->getIterator() as $file) { foreach ($report->getIterator() as $file) {
if (! $file instanceof 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)); 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()) { if ($this->hasHigherOrderCallable()) {
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments); return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
} }
try { try {

View File

@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
*/ */
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void 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}; return $this->target->{$property};
} }
$className = (new ReflectionClass($this->target))->getName(); $className = new ReflectionClass($this->target)->getName();
if (str_starts_with($className, 'P\\')) { if (str_starts_with($className, 'P\\')) {
$className = substr($className, 2); $className = substr($className, 2);
@ -60,7 +60,7 @@ final class HigherOrderTapProxy
$filename = Backtrace::file(); $filename = Backtrace::file();
$line = Backtrace::line(); $line = Backtrace::line();
return (new HigherOrderMessage($filename, $line, $methodName, $arguments)) return new HigherOrderMessage($filename, $line, $methodName, $arguments)
->call($this->target); ->call($this->target);
} }
} }

View File

@ -181,7 +181,7 @@ final class Reflection
*/ */
public static function getFunctionArguments(Closure $function): array public static function getFunctionArguments(Closure $function): array
{ {
$parameters = (new ReflectionFunction($function))->getParameters(); $parameters = new ReflectionFunction($function)->getParameters();
$arguments = []; $arguments = [];
foreach ($parameters as $parameter) { foreach ($parameters as $parameter) {
@ -207,7 +207,7 @@ final class Reflection
public static function getFunctionVariable(Closure $function, string $key): mixed 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\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder; use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered; use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered; use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered; use PHPUnit\Event\Test\PhpunitNoticeTriggered;
@ -40,11 +41,16 @@ final class StateGenerator
} }
foreach ($testResult->testFailedEvents() as $testResultEvent) { foreach ($testResult->testFailedEvents() as $testResultEvent) {
$state->add(TestResult::fromPestParallelTestCase( if ($testResultEvent instanceof Failed) {
$testResultEvent->test(), $state->add(TestResult::fromPestParallelTestCase(
TestResult::FAIL, $testResultEvent->test(),
$testResultEvent->throwable() TestResult::FAIL,
)); $testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
}
} }
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL); $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

@ -8,11 +8,6 @@ use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Graph;
/** /**
* Accepts a test file only if it is in the TIA-computed affected set.
*
* Falls back to accepting when the graph has no record of the file (new tests
* must always run) or when the file is outside the project root.
*
* @internal * @internal
*/ */
final readonly class TiaTestCaseFilter implements TestCaseFilter final readonly class TiaTestCaseFilter implements TestCaseFilter

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