mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
312 Commits
v4.6.3
..
e90e4f70fc
| Author | SHA1 | Date | |
|---|---|---|---|
| e90e4f70fc | |||
| d112582857 | |||
| 9f3c4e1e82 | |||
| 71f39366c2 | |||
| bfd5b75677 | |||
| 411b9954b5 | |||
| db70017cb2 | |||
| 86adaedbbd | |||
| 92c7677c6e | |||
| 872f0a50c2 | |||
| 668809bc75 | |||
| 8d8f45c843 | |||
| 086b3e9107 | |||
| 43fe26f324 | |||
| 865c1e5113 | |||
| 19eed8d581 | |||
| ad1850b110 | |||
| 585de259a2 | |||
| 953664dce0 | |||
| 1dd959848c | |||
| 047753c836 | |||
| b63626a94d | |||
| ae080becd6 | |||
| aa86304794 | |||
| 75963ea973 | |||
| 5d0ceb1a5d | |||
| 6f0c0536a4 | |||
| 6b2cd358e8 | |||
| e09e1be76d | |||
| 759ac63139 | |||
| 333f469efb | |||
| c4ec925aa5 | |||
| 5c8cfed483 | |||
| 962510b7e3 | |||
| e9c268a584 | |||
| fa91fcf744 | |||
| c6073511a2 | |||
| b5e1b83bb4 | |||
| 1c11595504 | |||
| cd0e921158 | |||
| f8c4c061f8 | |||
| 81eacd79fd | |||
| 1ef680c75d | |||
| 820fa08313 | |||
| 9e79e491e2 | |||
| 1b83b8fcda | |||
| ff4aba2ffa | |||
| feaac1ab7e | |||
| bbc6ada21e | |||
| 3927dbfcdf | |||
| b52bbb4cca | |||
| 52819501eb | |||
| 7b18287374 | |||
| 33e3bf2888 | |||
| 72ab506d7b | |||
| 5dc49a71d6 | |||
| 1adb484d3f | |||
| aadf7e92a6 | |||
| df42b9ed7b | |||
| 632ae79c8b | |||
| b5fed82883 | |||
| 145e20db75 | |||
| ee2e97e932 | |||
| b561236325 | |||
| e685179b7e | |||
| ac8feafdcc | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 87882a8561 | |||
| 77ef7e0df1 | |||
| 12100dd901 | |||
| 20c12d006e | |||
| 3aab02d2eb | |||
| 553aac65e6 | |||
| be49a3ce18 | |||
| 774a340400 | |||
| 04c9d41895 | |||
| dfb7b870af | |||
| 37821e1108 | |||
| cd711a25d5 | |||
| eee60c9e11 | |||
| c40c8dbc24 | |||
| 2d93c9c373 | |||
| 3d5bba93f8 | |||
| e5ab3af05b | |||
| 79bc7a8257 | |||
| 40b88b62ef | |||
| e3361bc321 | |||
| fc48c1bd1e | |||
| 92e76eb5ab | |||
| da726beffc | |||
| 4ef12b9aac | |||
| bd22f478b8 | |||
| eeaac34cf6 | |||
| b9b07d8983 | |||
| 6aa7d2f891 | |||
| 4d550cecfd | |||
| 1c21a7647a | |||
| d649de1988 | |||
| 783ca4bcd6 | |||
| ba07497219 | |||
| 34695843b3 | |||
| 1ca021dea6 | |||
| d17be9decd | |||
| b828ddcec7 | |||
| f859bb179d | |||
| 2fc75cfcf0 | |||
| 6cc48f63f8 | |||
| e0419d1328 | |||
| faa6988801 | |||
| c12247fafd | |||
| 29b4452443 | |||
| 1b168aba1c | |||
| 6aabd977cd | |||
| a882543c53 | |||
| c250b9da4f | |||
| 46bc3dc628 | |||
| d3ce498b8a | |||
| e1a4b98b71 | |||
| 9afbcd5c18 | |||
| 75593b6454 | |||
| 89590d6120 | |||
| fb0978c9bf | |||
| a3796daa42 | |||
| e3004db666 | |||
| 99cc4e0146 | |||
| a47e6f8fef | |||
| 536d79f765 | |||
| 65c0fbc528 | |||
| 9e4cf4b665 | |||
| 7bea819978 | |||
| 4280233b40 | |||
| d6db3a8a20 | |||
| 51c8ce4df6 | |||
| 5b8393b925 | |||
| e4d9b61fdf | |||
| e2d940cd53 | |||
| 380ccd30b4 | |||
| 31c200716d | |||
| 6add4da543 | |||
| 8ddcd3e853 | |||
| e3e178fd94 | |||
| 7b1ec9f003 | |||
| 1e48c5d473 | |||
| d00ec95dd9 | |||
| 89f3d6cb39 | |||
| a07a2e512a | |||
| 57eecb2b3d | |||
| 9f804dc954 | |||
| 7cbad4c589 | |||
| 5cae93b059 | |||
| df829ad19d | |||
| 635460653c | |||
| 1aa80dc398 | |||
| 8a14056111 | |||
| f247dd8e7b | |||
| 1c7c9754fd | |||
| 5f37939fda | |||
| 28305fcb7a | |||
| 5242803694 | |||
| 925935a7e8 | |||
| 460401c379 | |||
| 348b439172 | |||
| a4e77766c5 | |||
| 4a8c2d7d78 | |||
| 7d51601120 | |||
| 631bbe318b | |||
| 9b7c15d5b6 | |||
| 872796bd9b | |||
| c38d32ae86 | |||
| 6407c4f78f | |||
| 6e1bf63f6a | |||
| 1d3e8bb5dd | |||
| 3cc9b169e3 | |||
| c4911d046b | |||
| d0295f6168 | |||
| 21efbc3107 | |||
| e59b99cd73 | |||
| bf48e20880 | |||
| 53db68e005 | |||
| 34f1e9a7f2 | |||
| 57fd5ce042 | |||
| 3bcabfb63b | |||
| aa3a7c303a | |||
| 5c08a135f7 | |||
| 6e0e030d71 | |||
| b2c07561e7 | |||
| 97600b6f0b | |||
| 8a51f15d65 | |||
| a349f53964 | |||
| a725e774c0 | |||
| bed5e5b54a | |||
| 45b1d4ce20 | |||
| d106b70766 | |||
| 6ac6c1518e | |||
| fda515a17f | |||
| 0a97d3a288 | |||
| 3802fa80e6 | |||
| 5c3cbc14d2 | |||
| 6b9c768172 | |||
| 4a2fc179ae | |||
| b5bb2139dc | |||
| 07416a3c61 | |||
| 30b94e3034 | |||
| be34eecb2f | |||
| 5d9f95f8d4 | |||
| 48b70a03d5 | |||
| 4b8642b972 | |||
| 8711d51eac | |||
| 58dfb6da64 | |||
| d7735d1faa | |||
| 6b59166f3c | |||
| 3a26028d17 | |||
| 3c91bf4ad2 | |||
| 6a434be0f6 | |||
| f355b99bbf | |||
| 95a00341e9 | |||
| 466259646d | |||
| 00f8d56083 | |||
| ca2dca592d | |||
| 405d8d4406 | |||
| b944ee5841 | |||
| f4e22dcafe | |||
| 339c1e8cac | |||
| d4c7362132 | |||
| 81bfdbf8fe | |||
| f45cbf43c5 | |||
| b9088d23fb | |||
| 7250185423 | |||
| e457eb0e9c | |||
| 48357c6f30 | |||
| b46f051550 | |||
| 3d3c5d41ac | |||
| caabebf2a1 | |||
| 470a5833d4 | |||
| c1feefbb9e | |||
| e876dba8ba | |||
| d9c18f9c02 | |||
| 660b57b365 | |||
| 68527c996f | |||
| c6a42a2b28 | |||
| fcf5c27914 | |||
| 856a370032 | |||
| e24882c486 | |||
| 51fc380789 | |||
| f6609f4039 | |||
| 2941f9821f | |||
| ed399af43e | |||
| 0d66dc4322 | |||
| 7e4280bf83 | |||
| a5915b16ab | |||
| 1476b529a1 | |||
| 2892341c28 | |||
| 59e781e77b | |||
| 55a3394f8c | |||
| 0d99c33c4e | |||
| adc5aae6f8 | |||
| 980667e845 | |||
| 8c849c5f40 | |||
| 47f1fc2d94 | |||
| 9c8033d60c | |||
| 42d1092a9e | |||
| c7e32f5d33 | |||
| d379128cc4 | |||
| f09d6f2064 | |||
| 494cc6e2a4 | |||
| f52a455773 | |||
| 184f5d2742 | |||
| 1d81069a2a | |||
| 4b9bb77b54 | |||
| c440031e28 | |||
| 18bbca748f | |||
| f142aad8ad | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| e63a886f98 | |||
| 8dd650fd05 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| 668ca9f5de | |||
| f659a45311 | |||
| 12c1da29ee | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 |
@@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
|
||||
|
||||
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
|
||||
|
||||
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
|
||||
|
||||
All security vulnerabilities will be promptly addressed.
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -2,7 +2,7 @@ name: Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -24,16 +24,16 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
dependency-version: [prefer-lowest, prefer-stable]
|
||||
dependency-version: [prefer-stable]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
@@ -44,15 +44,17 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.3-composer-
|
||||
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.4-composer-
|
||||
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||
|
||||
- name: Profanity Check
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -24,21 +24,18 @@ jobs:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||
symfony: ['7.4', '8.0']
|
||||
php: ['8.3', '8.4', '8.5']
|
||||
symfony: ['8.0']
|
||||
php: ['8.4', '8.5']
|
||||
dependency_version: [prefer-stable]
|
||||
exclude:
|
||||
- php: '8.3'
|
||||
symfony: '8.0'
|
||||
|
||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
@@ -51,7 +48,7 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
@@ -66,6 +63,8 @@ jobs:
|
||||
|
||||
- name: Install PHP dependencies
|
||||
shell: bash
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||
|
||||
- name: Unit Tests
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
.idea/*
|
||||
.idea/codeStyleSettings.xml
|
||||
.temp/*
|
||||
composer.lock
|
||||
/composer.lock
|
||||
/vendor/
|
||||
coverage.xml
|
||||
.phpunit.result.cache
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# CLAUDE.md
|
||||
|
||||
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
|
||||
|
||||
## Ask before testing
|
||||
|
||||
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
|
||||
|
||||
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
|
||||
|
||||
Once the user confirms:
|
||||
|
||||
```bash
|
||||
composer test:unit # fast, excludes the visual group
|
||||
composer test:integration # visual and snapshot tests
|
||||
composer test # everything CI runs, in CI's order
|
||||
composer update:snapshots # only when a test was added or removed
|
||||
```
|
||||
@@ -0,0 +1,342 @@
|
||||
# TIA write-tier conformance — plan
|
||||
|
||||
Baseline sweep: **147 cases, 144 PASS / 2 FAIL / 1 SKIP** against `dev-fix/tia-filtered` (`db70017`)
|
||||
on `playground/laravel` with pcov enabled.
|
||||
|
||||
This file records (1) what is left to fix, and (2) the full case matrix with the target
|
||||
outcome for every row, so the sweep can be re-run as a conformance check after the fixes land.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — What's left
|
||||
|
||||
### 1. `--parallel` silently under-records `edges` (G12, I5) — **high**
|
||||
|
||||
Any `--tia --parallel` complete run writes `files=4` where sequential writes `files=25`. Only
|
||||
`app/**` and blade views survive; every `config/`, `routes/`, `bootstrap/` edge and every test
|
||||
self-edge is lost. A `routes/web.php` edit that sequential TIA catches, parallel TIA reports as
|
||||
`No affected tests found`.
|
||||
|
||||
Root cause — `src/Restarters/PcovRestarter.php:33`:
|
||||
|
||||
```php
|
||||
if (! Tia::isEnabledForRun($arguments)) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`Tia::isEnabledForRun()` (`src/Plugins/Tia.php:275-288`) returns true only for `--tia` in argv or
|
||||
the `PEST_TIA` env flag. A paratest worker's argv carries neither, so workers never re-exec with
|
||||
`-d pcov.directory=<projectRoot>` and record under pcov's empty default scope.
|
||||
|
||||
Proven both directions:
|
||||
- `PEST_PCOV_RESTARTER_RESTARTED=1 pest --tia` (sequential, restart suppressed) reproduces
|
||||
`files=4` with edge sets identical to the parallel graph.
|
||||
- `PEST_TIA=1 pest --parallel` restores the full `files=25` graph. **Working workaround today.**
|
||||
|
||||
Coverage config is irrelevant: widening `phpunit.xml` `<include>` to `app|config|routes|bootstrap|tests`
|
||||
still yields `files=4`.
|
||||
|
||||
Sticky, not just per-run: `pest --tia --filtered --parallel` re-records only the affected test
|
||||
through a worker and strips that entry's edges from an otherwise-healthy sequential graph, so one
|
||||
parallel run degrades a good graph incrementally.
|
||||
|
||||
**Fix direction:** propagate the TIA-enabled decision into workers (e.g. set `PEST_TIA` in the
|
||||
worker env, or have the parent stamp a recording global the restarter also consults) so
|
||||
`PcovRestarter` restarts them. Then assert `edges` equivalence between sequential and parallel.
|
||||
|
||||
### 2. Deleted test file with a cached failure wedges `--filtered` forever (I9) — **high**
|
||||
|
||||
Expected a WARN and a full-suite fallback. Actual: `--filtered` selects the phantom file, runs
|
||||
nothing, exits **0 green**, on every subsequent invocation.
|
||||
|
||||
Root cause — `src/Plugins/Tia/Graph.php:1523-1525`:
|
||||
|
||||
```php
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
}
|
||||
```
|
||||
|
||||
`relative()` falls back to the raw path when `realpath()` fails, so a deleted-but-in-project test
|
||||
file is never "unlocated", `Graph::hasUnlocatedTestsToRerun()` never fires, and the WARN branch at
|
||||
`src/Plugins/Tia.php:982-986` is unreachable for the deletion case. `pruneMissingTests()` only runs
|
||||
under `--fresh`, so nothing clears it.
|
||||
|
||||
**Fix direction:** distinguish "path could not be resolved" from "path resolved outside the root" in
|
||||
`relative()`, or have `hasUnlocatedTestsToRerun()` stat the file directly.
|
||||
|
||||
### 3. `--coverage-text` does not disable filtered mode (I8) — **low**
|
||||
|
||||
`coverageReportActive()` (`src/Plugins/Tia.php:1705-1710`) reads only Pest's own `--coverage`, so
|
||||
raw PHPUnit coverage flags (`--coverage-text`, `--coverage-html`, `--coverage-clover`, …) leave
|
||||
filtered mode on. `Tia::COVERAGE_FLAGS` at `:113-115` already enumerates them — that list is not
|
||||
consulted here.
|
||||
|
||||
### 4. `--coverage` narrows edges like parallel does — **medium, needs triage**
|
||||
|
||||
`pest --tia --coverage` prints `fresh graph (recording coverage baseline)` even when a graph
|
||||
exists, and writes `Feature/ExampleTest` with 2 files instead of 16, dropping self-edges. Same
|
||||
observable shape as G12, likely a different mechanism (piggyback collector scoped by
|
||||
`phpunit.xml <include>` rather than the pcov-restarted recorder). Theme: **edges silently narrow to
|
||||
`app/**` whenever recording does not go through the pcov-restarted TIA recorder.** Confirm whether
|
||||
these are one fix or two.
|
||||
|
||||
### 5. Smaller items
|
||||
|
||||
| Item | Where | Note |
|
||||
|---|---|---|
|
||||
| Warning/deprecation recorded as `status=0` | write path | Codes `6` and `4` appear unreachable; risky/skipped/incomplete record faithfully |
|
||||
| Complete non-TIA runs write edge-less results | `src/Plugins/Tia.php:1645`, `:638` | Guard is `! $complete && ! knowsTest()`; complete bypasses it and `markKnownTestFiles` stays false. Inert — replay guards on the same predicate at `:360` — but inflates `n` |
|
||||
| SIGINT never reaches the suite | `PcovRestarter` re-exec | Parent ignores it; the child holds PHPUnit's handler. CI `timeout`/Ctrl-C will not stop a run |
|
||||
| Structural drift never announced sequentially | `src/Plugins/Tia.php:1178-1181` | `enterRecordMode()` prints a bare `Running in TIA mode.`; `renderFreshGraph()` (`fresh graph (composer.lock changed)`) only runs under `--parallel` or coverage piggyback |
|
||||
| Replay clobbers cached `time` | write path | `0.058` → `0.001`; timing data degrades toward zero across runs |
|
||||
| `pest --parallel` without `--tia` writes nothing | G3, G6 | Sequential `pest --filter=…` does record. Parallel CI contributes nothing to the cached-failure replay path |
|
||||
| `--tia --parallel --filter`/`--shard` record nothing | G2, G8 | More conservative than the results-only contract requires |
|
||||
| `--min=50` without `--coverage` is a silent no-op | — | — |
|
||||
| `pest --repeat=2` is not a Pest option | J11 | Case unrunnable as written; drop it or add the option |
|
||||
|
||||
### 6. Test-harness gaps to close before re-running
|
||||
|
||||
- The playground has no `UsesClass`, `->note()`, `->flaky()`, `->issue()`, `->pr()`, `->ticket()` or
|
||||
`->assignee()` annotations, so **C19, C23–C28, C37–C39 matched zero tests** and their
|
||||
graph-invariant assertions proved nothing. Add annotated fixtures to make those rows load-bearing.
|
||||
- Use **sentinel patching** (rewrite every cached entry to `time=9.999 assertions=42`, then see which
|
||||
entries get overwritten) as the discriminator. It is the only way to tell "wrote identical values"
|
||||
from "wrote nothing". A canary test absent from `edges` is unreliable under `--filter` because it
|
||||
never matches the filter and so never runs.
|
||||
- `--tia` on a clean green tree can never cache a failure (unchanged tests replay rather than
|
||||
execute); seeding one requires `--fresh` or an env-driven flaky fixture.
|
||||
- Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Full case matrix (target outcomes)
|
||||
|
||||
Tiers: **COMPLETE** may change everything · **RESULTS-ONLY** (RO) may change only
|
||||
`baselines[<branch>].results` for tests that ran, and must never remove an entry, add a result for a
|
||||
test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||
**HARD-SUPPRESSED** may change nothing.
|
||||
|
||||
Status column: `PASS` = conforming today · `FIX` = must pass once the item above lands ·
|
||||
`SKIP` = case not runnable as written.
|
||||
|
||||
### A — Setup & sanity
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| A1 | `composer show pestphp/pest` | version `dev-fix/tia-filtered` | PASS |
|
||||
| A2 | `pest --baseline` | prints an existing dir; exit 0 | PASS |
|
||||
| A3 | delete graph, `pest --tia` | graph.json created; one `edges` key per test file; one result per test | PASS |
|
||||
| A4 | `git status` after reset ritual | clean | PASS |
|
||||
| A5 | `extension_loaded("pcov")` | `true` | PASS |
|
||||
| A6 | delete graph, plain `pest` | no graph created | PASS |
|
||||
| A7 | delete graph, `pest --filter=adds` | no graph created | PASS |
|
||||
| A8 | two consecutive `pest --tia` | second replays everything; `sha` unchanged | PASS |
|
||||
|
||||
### B — COMPLETE runs still write
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| B1 | `pest --tia` (clean) | replays; graph written; `sha` = `git rev-parse HEAD` | PASS |
|
||||
| B2 | `pest --no-tia` | results refreshed; prune applied; `sha`/`tree`/`edges` unchanged | PASS |
|
||||
| B3 | `pest` (plain) | same as B2 | PASS |
|
||||
| B4 | `pest --tia --fresh` | graph purged and rebuilt; `edges` rebuilt; `files` repopulated | PASS |
|
||||
| B5 | `pest --bail`, all green | COMPLETE — graph written, prune applied | PASS |
|
||||
| B6 | edit `Calculator.php`, `pest --tia` | `CalculatorTest` re-runs, others replay; `tree` updated | PASS |
|
||||
| B7 | delete `trio three`, `pest --tia` | its result pruned; `trio one`/`two` remain | PASS |
|
||||
| B8 | delete `GreeterTest.php`, `pest --tia` | result and edges survive (missing-file prune is `--fresh`-only) | PASS |
|
||||
| B9 | B8 then `pest --tia --fresh` | entry gone; `files` drops `Greeter.php` too | PASS |
|
||||
| B10 | `pest --tia` twice | `time` differs; statuses stable | PASS |
|
||||
| B11 | add a test file, `pest --tia` | new `edges` key + new result appear | PASS |
|
||||
| B12 | `pest --tia --coverage` | completes; graph written | PASS |
|
||||
|
||||
### C — Selection narrowing → RESULTS-ONLY
|
||||
|
||||
All rows: RO invariants hold. "Notice" = `TIA does not apply to partial runs — running the selected tests directly.`
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| C1 | `pest --filter="adds numbers"` | RO; no notice | PASS |
|
||||
| C2 | `pest --tia --filter="adds numbers"` | RO; notice | PASS |
|
||||
| C3 | `pest --filter="trio one"` | RO; `trio two`/`three` byte-identical | PASS |
|
||||
| C4 | `pest --filter="trio"` | RO; all three update; nothing else does | PASS |
|
||||
| C5 | `pest --exclude-filter="Feature"` | RO; no notice; no prune | PASS |
|
||||
| C6 | `pest --tia --exclude-filter="Feature"` | RO; notice | PASS |
|
||||
| C7 | `pest --group=smoke` | RO; no notice | PASS |
|
||||
| C8 | `pest --tia --group=smoke` | RO; notice | PASS |
|
||||
| C9 | `pest --exclude-group=smoke` | RO; no notice; no prune | PASS |
|
||||
| C10 | `pest --tia --exclude-group=smoke` | RO; notice | PASS |
|
||||
| C11 | `pest tests/Unit` | RO; no notice; Feature entry not pruned | PASS |
|
||||
| C12 | `pest --tia tests/Unit` | RO; notice | PASS |
|
||||
| C13 | `pest tests/Unit/TrioTest.php` | RO; no notice | PASS |
|
||||
| C14 | `pest --tia tests/Unit/TrioTest.php` | RO; notice | PASS |
|
||||
| C15 | `pest --testsuite=Unit` | RO; no notice | PASS |
|
||||
| C16 | `pest --tia --testsuite=Unit` | RO; notice | PASS |
|
||||
| C17 | `pest --tia --exclude-testsuite=Feature` | RO; notice | PASS |
|
||||
| C18 | `pest --tia --covers='App\Services\Calculator'` | RO; notice | PASS |
|
||||
| C19 | `pest --tia --uses='App\Services\Calculator'` | RO; notice | PASS (vacuous — needs fixture) |
|
||||
| C20 | `pest --tia --test-suffix=Test.php` | RO even though all tests run; `edges`/`files`/`n` unchanged | PASS |
|
||||
| C21 | `pest --dirty` with an uncommitted test edit | RO; no notice | PASS |
|
||||
| C22 | `pest --tia --dirty` | RO; notice | PASS |
|
||||
| C23 | `pest --tia --todos` | RO; notice | PASS (vacuous) |
|
||||
| C24 | `pest --tia --notes` | RO; notice | PASS (vacuous) |
|
||||
| C25 | `pest --tia --flaky` | RO; notice | PASS (vacuous) |
|
||||
| C26 | `pest --tia --issue=123` | RO; notice | PASS (vacuous) |
|
||||
| C27 | `pest --tia --pr=1` | RO; notice | PASS (vacuous) |
|
||||
| C28 | `pest --tia --pull-request=1` | RO; notice | PASS (vacuous) |
|
||||
| C29 | `pest --tia --shard=1/2` | RO; notice; no prune | PASS |
|
||||
| C30 | `pest --tia --shard=2/2` | RO; notice; C29+C30 covers the suite; neither prunes | PASS |
|
||||
| C31 | `->only()` on `trio one`, `pest --tia` | RO; no notice; siblings untouched | PASS |
|
||||
| C32 | `->only()` on `trio one`, plain `pest` | RO; no notice | PASS |
|
||||
| C33 | `pest --tia --filtered --filter=adds` | RO; notice; filtered yields to narrowing | PASS |
|
||||
| C34 | `PEST_TIA=1 pest --filter=adds` | RO; notice | PASS |
|
||||
| C35 | `PEST_TIA_FILTERED=1 pest --filter=adds` | RO; notice (regression guard) | PASS |
|
||||
| C36 | `pest --filtered --filter=adds` | RO; notice | PASS |
|
||||
| C37 | `pest --tia --ticket=X` | RO; notice. `--ticket` **is** a real option (`src/Plugins/Snapshot.php:83`) — it narrows legitimately | PASS (vacuous) |
|
||||
| C38 | `pest --tia --assignee=X` | RO; notice (`src/Plugins/Snapshot.php:81`) | PASS (vacuous) |
|
||||
| C39 | `pest --tia --todo` | RO; notice; matches nothing | PASS (vacuous) |
|
||||
|
||||
### D — Truncation → RESULTS-ONLY
|
||||
|
||||
D1–D5, D11–D13, D16 precondition: `trio one` broken. D6–D10 run against a green suite so the `--stop-on-*` trigger is the only narrowing.
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| D1 | `pest --bail` | RO; `trio one` = `status=7`; siblings survive unchanged | PASS |
|
||||
| D2 | `pest --retry` | RO; siblings survive | PASS |
|
||||
| D3 | `pest --stop-on-failure` | RO | PASS |
|
||||
| D4 | `pest --stop-on-defect` | RO | PASS |
|
||||
| D5 | `pest --stop-on-error` (no error occurs) | COMPLETE — nothing stopped it | PASS |
|
||||
| D6 | `pest --stop-on-warning` | RO when it fires. Warning stored as `status=0`, not `6` | PASS |
|
||||
| D7 | `pest --stop-on-risky --disallow-test-output` | RO; `status=5` | PASS |
|
||||
| D8 | `pest --stop-on-skipped` | RO; `status=1` | PASS |
|
||||
| D9 | `pest --stop-on-incomplete` | RO; `status=2` | PASS |
|
||||
| D10 | `pest --stop-on-deprecation` | RO. Deprecation stored as `status=0`, not `4` | PASS |
|
||||
| D11 | `pest --tia --bail` | RO | PASS |
|
||||
| D12 | `pest --bail --filter=trio` | RO (narrowed *and* truncated) | PASS |
|
||||
| D13 | `stopOnFailure="true"` in `phpunit.xml`, plain `pest` | RO — caught via `stoppedEarly()`, not flag matching | PASS |
|
||||
| D14 | D13 config, green suite | COMPLETE | PASS |
|
||||
| D15 | `pest --bail`, last test in run order fails | RO — over-conservative but safe; nothing was skipped | PASS |
|
||||
| D16 | `pest --tia --filtered --bail`, broken affected test | RO | PASS |
|
||||
|
||||
### E — Result merge semantics
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| E1 | cached `flaky`=7, `FLAKY_OK=1 pest --filter=flaky` | flips to `status=0`; all other results byte-identical | PASS |
|
||||
| E2 | cached `flaky`=0, `pest --filter=flaky` (env unset) | flips to `status=7` | PASS |
|
||||
| E3 | after E1, `pest --tia --filtered` clean | `No affected tests found`; zero graph delta | PASS |
|
||||
| E4 | after E2, `pest --tia --filtered` | re-runs, `from 1 previously unsuccessful test` | PASS |
|
||||
| E5 | any partial run | `assertions` and `time` update for the test that ran | PASS |
|
||||
| E6 | any partial run | `message` of untouched tests unchanged | PASS |
|
||||
| E7 | two sequential partial runs on different tests | each updates only its own entry; both persist | PASS |
|
||||
| E8 | `pest --filter="trio one"` | siblings keep exact `status`/`time`/`assertions`/`message` | PASS |
|
||||
| E9 | partial run of a `->skip()`ed test | `status=1` | PASS |
|
||||
| E10 | partial run of a `->todo()` test | `status=1`, `assertions=0`, `message="__TODO__"` | PASS |
|
||||
| E11 | partial run of a risky test | `status=5` | PASS |
|
||||
| E12 | any partial run | `fingerprint` byte-identical | PASS |
|
||||
| E13 | dataset test, `--filter` matching one row | that row updates; other rows survive (the old prune bug) | PASS |
|
||||
| E14 | dataset test, full `--tia` after deleting a row | the deleted row is pruned | PASS |
|
||||
|
||||
### F — Guard rails
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| F1 | test absent from `edges`, `pest --filter="brand new"` | no result recorded | PASS |
|
||||
| F2 | complete `pest --tia` first, then the same filter | result is recorded | PASS |
|
||||
| F3 | `pest --mutate --path=app/Services --covered-only` | statuses/`edges`/`sha`/`tree`/`files` identical; only `time` may change | PASS |
|
||||
| F4 | `pest --mutate --parallel --path=… --covered-only` | same (observed: zero delta) | PASS |
|
||||
| F5 | weakened test so a mutant survives, `pest --mutate` | no status change in the baseline | PASS |
|
||||
| F6 | `pest --mutate` with the graph deleted | no graph created | PASS |
|
||||
| F7 | mutation subprocess env | carries `PEST_MUTATION_TESTING`; `--mutate` stripped from argv | PASS |
|
||||
| F8 | grep baseline after `--mutate` | no mutation-flavoured messages | PASS |
|
||||
|
||||
### G — Parallel
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| G1 | `pest --tia --parallel` (clean) | COMPLETE — graph written | PASS |
|
||||
| G2 | `pest --tia --parallel --filter=adds` | RO | PASS |
|
||||
| G3 | `pest --parallel --bail`, `trio one` broken | RO; siblings survive | PASS |
|
||||
| G4 | `pest --tia --parallel --bail` | RO | PASS |
|
||||
| G5 | `pest --tia --filtered --parallel` after a source edit | narrows to affected; writes | PASS (but see G12 — it strips edges) |
|
||||
| G6 | `pest --tia --parallel` | worker results reach the parent baseline | PASS |
|
||||
| G7 | `->only()` + `pest --tia --parallel` | RO | PASS |
|
||||
| G8 | `pest --tia --parallel --shard=1/2` | RO | PASS |
|
||||
| G9 | broken test in one worker, `pest --parallel --bail` | whole run is RO, not just that worker's slice | PASS |
|
||||
| G10 | `pest --parallel --retry` | `InvalidOption`; graph untouched | PASS |
|
||||
| G11 | `pest --tia --parallel --fresh` | graph rebuilt | PASS |
|
||||
| G12 | `edges` after `pest --tia` vs `pest --tia --parallel --fresh` | **equivalent edge sets** (`files=25` both) | **FIX (item 1)** |
|
||||
|
||||
### H — Baseline key / branch resolution
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| H1 | `pest --tia` on `master` | only a `master` key | PASS |
|
||||
| H2 | plain `pest` on `master` | writes `master`, not `main` | PASS |
|
||||
| H3 | `pest --bail` truncated on `master` | no `main` key minted | PASS |
|
||||
| H4 | `pest --filter=adds` on `master` | no `main` key minted | PASS |
|
||||
| H5 | `git branch -m main`, full `pest --tia` | single `main` key | PASS |
|
||||
| H6 | `git checkout -b feature/x`, `pest --tia` | `feature/x` key appears; reads fall back to the existing baseline | PASS |
|
||||
| H7 | detached HEAD, `pest --tia` | falls back to the `main` key for both reads and writes; no `HEAD` key | PASS |
|
||||
| H8 | after every C and D case | no baseline key other than the real branch | PASS |
|
||||
| H9 | non-git dir, `pest --tia` | `MissingDependency` — `The feature "Tia mode" requires "git".` | PASS |
|
||||
| H10 | non-git dir, plain `pest` | runs normally; no baseline dir created | PASS |
|
||||
|
||||
### I — Filtered mode
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| I1 | clean + green, `pest --tia --filtered` | `No affected tests found`; zero graph delta | PASS |
|
||||
| I2 | edit `Calculator.php` | only `CalculatorTest` runs | PASS |
|
||||
| I3 | cached failure, clean tree | re-runs it, `from 1 previously unsuccessful test` | PASS |
|
||||
| I4 | I2 with sentinels | `tree` updated; exactly one entry rewritten; all others retained | PASS |
|
||||
| I5 | `pest --tia --filtered --parallel` | same narrowing as I2 **and `edges` unchanged** | **FIX (item 1)** |
|
||||
| I6 | `PEST_TIA_FILTERED=1 pest --tia` | identical delta to I4 | PASS |
|
||||
| I7 | `pest --tia --filtered tests/Unit` | explicit path wins; filtered off; RO; notice | PASS |
|
||||
| I8 | `pest --tia --filtered --coverage-text` | filtered mode disabled by an active coverage report | **FIX (item 3)** |
|
||||
| I9 | cached failure whose test file was deleted | WARN `could not be located on disk`; falls back to the full suite with replay | **FIX (item 2)** |
|
||||
| I10 | `pest --tia --filtered` with no baseline yet | records a baseline instead of filtering | PASS |
|
||||
| I11 | edit a Blade view a Feature test renders | the Feature test is selected | PASS |
|
||||
| I12 | edit `composer.lock` | fingerprint drift → full rebuild (and the drift reason should be printed — item 5) | PASS |
|
||||
|
||||
### J — Interactions & regressions
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| J1 | `pest --tia --fresh --filter=adds` | graph **not** purged; RO | PASS |
|
||||
| J2 | `pest --tia --refetch --filter=adds` | refetch not performed; RO | PASS |
|
||||
| J3 | `pest --no-tia --filter=adds` | RO — narrowing wins over `--no-tia`; no notice | PASS |
|
||||
| J4 | `pest --tia --no-tia` | TIA disabled; COMPLETE | PASS |
|
||||
| J5 | `<groups><exclude><group>integration</group></exclude></groups>`, `pest --tia` | COMPLETE, no notice — an always-in-force filter applies to baseline runs too | PASS |
|
||||
| J6 | `pest --tia --compact` | COMPLETE | PASS |
|
||||
| J7 | `pest --tia -v` | COMPLETE | PASS |
|
||||
| J8 | `pest --tia --profile` | COMPLETE | PASS |
|
||||
| J9 | `pest --tia --order-by=random` | COMPLETE | PASS |
|
||||
| J10 | `pest --tia --random-order-seed=1234` | COMPLETE | PASS |
|
||||
| J11 | `pest --tia --repeat=2` | COMPLETE — repetition is not narrowing | **SKIP** — `--repeat` is not a Pest option |
|
||||
| J12 | `pest --filter=adds` twice | only the executed test's `time` differs; rest byte-identical | PASS |
|
||||
| J13 | `pest --tia --min=50` | COMPLETE | PASS |
|
||||
| J14 | SIGINT mid-suite | RO — an interrupted run is truncated | PASS (signal must go to the re-exec'd child — item 5) |
|
||||
| J15 | delete the graph, `pest --tia --filtered` | records a baseline rather than erroring | PASS |
|
||||
| J16 | corrupt `graph.json`, `pest --tia` | recovers by rebuilding; no crash | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Re-run procedure
|
||||
|
||||
```bash
|
||||
cd /Users/nunomaduro/Work/projects/playground/laravel
|
||||
GRAPH="$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)/graph.json"
|
||||
|
||||
# per case
|
||||
git checkout -q . && git clean -qfd tests app
|
||||
rm -rf "$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)"
|
||||
PAO_DISABLE=1 ./vendor/bin/pest --tia >/dev/null 2>&1
|
||||
# sentinel-patch every result to time=9.999 assertions=42, snapshot, run the case, diff
|
||||
```
|
||||
|
||||
Every pest invocation must be prefixed with `PAO_DISABLE=1` (the app has `laravel/pao`, which emits
|
||||
JSON when it detects an agent). The shell is zsh — build commands with arrays or `eval`; unquoted
|
||||
`$args` does not word-split.
|
||||
@@ -0,0 +1,455 @@
|
||||
# TIA default-branch fallback — phase three
|
||||
|
||||
## Your task
|
||||
|
||||
Fix [pestphp/pest#1823](https://github.com/pestphp/pest/issues/1823) — TIA's cached results never
|
||||
hit for repos whose default branch is not literally `main` — then re-run the **whole** conformance
|
||||
matrix against the playground app, plus the new section **L** that covers the fix.
|
||||
|
||||
Work in this order, and **stop where the plan says stop**:
|
||||
|
||||
1. **Part 1** — read the diagnosis. It is already measured; do not re-derive it, but do re-confirm
|
||||
the two reproductions in Part 1.3 take ~2 minutes and prove your environment is sane.
|
||||
2. **Part 2** — implement the fix in the `pestphp/pest` repo. **Do not commit. Do not touch the
|
||||
playground's `vendor/`.**
|
||||
3. **HARD STOP → Part 3.** Report the diff to Nuno and wait. He validates, commits, and syncs it
|
||||
into the playground himself. You must not proceed until he confirms.
|
||||
4. **Part 4** — verify the sync landed, then run section **L** (new) and the full phase-two matrix
|
||||
(**A–K**, all 156 rows) against the playground.
|
||||
5. **Part 5** — report in the given format.
|
||||
|
||||
Per `CLAUDE.md`: **do not write new `pestphp/pest` unit tests and do not run `composer test`.** Make
|
||||
the change, report it, and ask whether repo tests should be added. The section-L rows are *playground
|
||||
invocations*, not repo tests — those are the deliverable and are always in scope.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — The diagnosis (already measured; commit `bfd5b756`)
|
||||
|
||||
### 1.1 Root cause — two independent hardcoded `'main'` literals
|
||||
|
||||
**(a) The read fallback.** `src/Plugins/Tia/Graph.php` — seven methods default the fallback to the
|
||||
literal `'main'`:
|
||||
|
||||
| line | method |
|
||||
|---|---|
|
||||
| 579 | `recordedAtSha(string $branch, string $fallbackBranch = 'main')` |
|
||||
| 614 | `getAssertions(…, string $fallbackBranch = 'main')` |
|
||||
| 625 | `getTime(…, string $fallbackBranch = 'main')` |
|
||||
| 636 | `getResult(…, string $fallbackBranch = 'main')` |
|
||||
| 663 | `testFilesToRerun(string $branch, string $fallbackBranch = 'main')` |
|
||||
| 700 | `hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main')` |
|
||||
| 811 | `lastRunTree(string $branch, string $fallbackBranch = 'main')` |
|
||||
|
||||
They all funnel into `Graph::baselineFor()` (line 819), which *does* implement a real cross-branch
|
||||
fallback:
|
||||
|
||||
```php
|
||||
if (isset($this->baselines[$branch])) return $this->baselines[$branch];
|
||||
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) return $this->baselines[$fallbackBranch];
|
||||
return ['sha' => null, 'tree' => [], 'results' => []];
|
||||
```
|
||||
|
||||
The mechanism is deliberate. The problem is that **no caller ever passes `$fallbackBranch`** — all
|
||||
nine call sites in `src/Plugins/Tia.php` (lines 419, 429, 432, 785, 857, 1026, 1031, 1048, 1056) pass
|
||||
only `$this->branch`. So on a `master`-named repo the second branch of `baselineFor()` can never
|
||||
fire, and the whole mechanism is dead code.
|
||||
|
||||
`grep -rniE 'defaultBranch|symbolic-ref|init\.defaultBranch|origin/HEAD' src/` returns **nothing** —
|
||||
no default-branch resolution exists anywhere.
|
||||
|
||||
**(b) The detached-HEAD default.** `src/Plugins/Tia.php:206` declares `private string $branch = 'main';`
|
||||
and `ChangedFiles::currentBranch()` (`ChangedFiles.php:208`) returns `null` for detached HEAD. So on
|
||||
detached HEAD `$this->branch` stays the literal `'main'` and is used for **both reads and writes** —
|
||||
minting a baseline key for a branch that does not exist. This is a *write*-side bug and a separate fix
|
||||
from (a).
|
||||
|
||||
### 1.2 Not a regression
|
||||
|
||||
`git log -S"fallbackBranch = 'main'"` bottoms out at `c7e32f5d feat(tia): continues to work on poc`.
|
||||
This is original PoC code, untouched by phase one. Phase one's change 1 modified
|
||||
`hasUnlocatedTestsToRerun()`'s file-existence check — one of the seven methods — without going near
|
||||
the fallback. Do not report it as a phase-one regression.
|
||||
|
||||
No test anywhere exercises the mechanism: `tests/Unit/Plugins/Tia/Graph.php` uses `'main'` as the
|
||||
*actual* branch name, so those assertions pass whether or not the fallback exists. A branch-name
|
||||
mismatch is never tested.
|
||||
|
||||
### 1.3 The two reproductions (re-confirm these before you start)
|
||||
|
||||
Both on the playground, `master`-named default branch, zero local changes:
|
||||
|
||||
```
|
||||
default = master default = main
|
||||
1. record on default → full run (cold) → full run (cold)
|
||||
2. 1st run on feature-x → 25 UNCACHED → 25 replayed ← (a)
|
||||
3. 2nd run on feature-x → 25 replayed → 25 replayed
|
||||
4. back on default → 25 replayed → 25 replayed
|
||||
5. 1st run on feature-y → 25 UNCACHED → 25 replayed
|
||||
```
|
||||
|
||||
```
|
||||
master-only graph, then `git checkout --detach`, then `pest --tia`:
|
||||
→ 25 uncached, and keys become [master,main] ← (b) spurious key
|
||||
```
|
||||
|
||||
The cost is **one full run per new branch, forever**, with no output explaining why — the only clue
|
||||
is the `N uncached` count; the headline is just `─ Experimental TIA mode enabled.`
|
||||
|
||||
### 1.4 Correction to phase two
|
||||
|
||||
Phase two reported **H6 and H7 as passing. Both were false passes.** They ran after H5, which had
|
||||
renamed `master`→`main` and left a `main` key in the graph, so the hardcoded fallback resolved by
|
||||
accident. Re-measured against a clean `master`-only graph, both fail. Section L replaces them as the
|
||||
load-bearing rows; H6/H7 must be re-run **from a cold graph** this time (see Part 4.2).
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — The fix to implement
|
||||
|
||||
### 2.1 Recommended design
|
||||
|
||||
**Step 1 — add a non-throwing resolver** to `src/Plugins/Tia/ChangedFiles.php`, next to
|
||||
`currentBranch()`:
|
||||
|
||||
```php
|
||||
public function defaultBranch(): ?string
|
||||
```
|
||||
|
||||
Resolution order, each step failing soft to the next:
|
||||
|
||||
1. `git symbolic-ref --short refs/remotes/origin/HEAD` → strip a leading `origin/`
|
||||
2. `git config --get init.defaultBranch`
|
||||
3. `null`
|
||||
|
||||
Unlike `currentBranch()`, this must **never throw** `MissingDependency` — it is advisory. Return
|
||||
`null` on any non-zero exit or empty output.
|
||||
|
||||
**Step 2 — add a config surface.** `src/Plugins/Tia/Configuration.php` already exposes `always()`,
|
||||
`locally()`, `filtered()`, `baselined()`, `watch()`. Add:
|
||||
|
||||
```php
|
||||
public function defaultBranch(string $branch): self
|
||||
```
|
||||
|
||||
so `pest()->tia()->defaultBranch('master')` works in `tests/Pest.php`. Explicit config **always
|
||||
wins** over autodetection — that is the escape hatch when `origin/HEAD` is unset.
|
||||
|
||||
**Step 3 — resolve once, in `Tia.php`.** The read path is hot (`getResult()` is called per test at
|
||||
line 419), so resolution must not shell out per call. Resolve alongside `$this->branch` at
|
||||
`Tia.php:1910`, under the existing `$branchResolved` guard:
|
||||
|
||||
```php
|
||||
$this->fallbackBranch = $configuredDefaultBranch
|
||||
?? $changedFiles->defaultBranch()
|
||||
?? 'main';
|
||||
```
|
||||
|
||||
**Step 4 — thread it into `Graph`.** Prefer a `Graph`-level property over editing nine call sites:
|
||||
add `Graph::setFallbackBranch(string $branch)`, change the seven signatures to
|
||||
`?string $fallbackBranch = null`, and resolve inside each with
|
||||
`$fallbackBranch ??= $this->fallbackBranch;`. `baselineFor()` itself needs no change. This keeps the
|
||||
public signatures backward-compatible and minimises blast radius.
|
||||
|
||||
**Step 5 — fix the detached-HEAD write.** `Tia.php:206`'s `= 'main'` default must become the
|
||||
resolved default branch, so detached HEAD stops minting a phantom key.
|
||||
|
||||
### 2.2 Invariants the fix must not break
|
||||
|
||||
These are all covered by existing matrix rows — the fix is wrong if any of them moves:
|
||||
|
||||
- **Read-only.** The fallback must affect *reads* only. Writes go through `ensureBaseline($branch)`
|
||||
and must keep using the real current branch. Otherwise H1–H4/H8 ("no baseline key other than the
|
||||
real branch") break.
|
||||
- **H9** — a non-git dir with `--tia` must still raise
|
||||
`MissingDependency: The feature "Tia mode" requires "git".` Adding a soft resolver must not
|
||||
swallow that.
|
||||
- **H10** — plain `pest` in a non-git dir must still run and create no baseline dir.
|
||||
- **A2/A3** — cold-graph recording unchanged.
|
||||
- **I1/E3** — clean+green `--tia --filtered` must still be a true zero-delta run.
|
||||
- Filtered mode reads `testFilesToRerun()` and `hasUnlocatedTestsToRerun()`, so the fallback must
|
||||
reach those two as well, not just `getResult()`.
|
||||
|
||||
### 2.3 Decisions for Nuno (raise these at the Part 3 stop)
|
||||
|
||||
- **D1** — Consult `origin/HEAD` at all? It requires `git remote set-head` and is absent in many CI
|
||||
checkouts and all remote-less repos. Config + `init.defaultBranch` only is simpler but helps fewer
|
||||
people out of the box. *Recommendation: keep it, first in the chain, since it fails soft.*
|
||||
- **D2** — Single-key heuristic: if the graph holds exactly one baseline key, use it as the fallback?
|
||||
Fixes the issue with zero git calls, but is implicit and surprising when several keys exist.
|
||||
*Recommendation: no.*
|
||||
- **D3** — Should detached HEAD write a baseline at all, or be read-only? Current behaviour mints a
|
||||
key. *Recommendation: read-only.*
|
||||
- **D4** — Should `pest()->tia()->defaultBranch()` validate that the branch exists, or accept any
|
||||
string? *Recommendation: accept any string; a nonexistent name degrades to a full run, which is
|
||||
safe.*
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — HARD STOP
|
||||
|
||||
When the code is written:
|
||||
|
||||
1. Show Nuno the diff (`git -C /Users/nunomaduro/Work/projects/pestphp/pest diff`) and a one-paragraph
|
||||
summary of each file's change.
|
||||
2. Answer/raise the D1–D4 decisions.
|
||||
3. State explicitly that you have **not** committed and have **not** synced `vendor/`.
|
||||
4. Ask whether repo unit tests should be added (per `CLAUDE.md`), describing the tests you have in
|
||||
mind — do not write them yet.
|
||||
5. **Wait.** Nuno commits and applies the change to the playground.
|
||||
|
||||
**Never sync the playground's `vendor/` yourself.** `vendor/pestphp/pest` there is a dist copy, not a
|
||||
symlink (composer installed `dev-fix/tia-filtered as 5.2.0`), so pest-repo edits do not reach it. Say
|
||||
what is stale and wait. This applies to before/after contrasts too.
|
||||
|
||||
Once he confirms, verify the sync actually landed before measuring anything:
|
||||
|
||||
```bash
|
||||
V=/Users/nunomaduro/Work/projects/playground/laravel/vendor/pestphp/pest
|
||||
grep -c 'defaultBranch' "$V/src/Plugins/Tia/ChangedFiles.php" # must be ≥ 1
|
||||
for f in src/Plugins/Tia.php src/Plugins/Tia/Graph.php src/Plugins/Tia/ChangedFiles.php \
|
||||
src/Plugins/Tia/Configuration.php; do
|
||||
diff -q "/Users/nunomaduro/Work/projects/pestphp/pest/$f" "$V/$f" >/dev/null \
|
||||
&& echo "SAME $f" || echo "STALE $f"
|
||||
done
|
||||
```
|
||||
|
||||
If anything reports `STALE`, stop and tell him. State in your final report which pest commit produced
|
||||
the playground numbers.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Measurement
|
||||
|
||||
### 4.1 Environment and traps
|
||||
|
||||
**Playground:** `/Users/nunomaduro/Work/projects/playground/laravel` (branch `master`).
|
||||
|
||||
1. **Every** pest invocation needs `PAO_DISABLE=1` — the app has `laravel/pao`, which emits JSON when
|
||||
it detects an agent.
|
||||
2. **Pin the interpreter to `php85`.** The playground requires PHP `>= 8.4.1` *and* pcov. Locally only
|
||||
`php85` (8.5.8) has both — `php84` (8.4.23) has no pcov, and the bare Herd `php` shim has been
|
||||
observed drifting to 8.3.32 mid-session, which kills every run in
|
||||
`vendor/composer/platform_check.php`. Also put a `php` → `php85` symlink first on `PATH`: the
|
||||
`--shard` list-tests probe spawns a subprocess via bare `php`, not `PHP_BINARY`.
|
||||
3. **Never run `git checkout .` or `git checkout -- composer.lock` in the playground.** It has four
|
||||
pre-existing user-modified files — `AGENTS.md`, `CLAUDE.md`, `composer.json`, `composer.lock`. Scope
|
||||
resets to `git checkout -- tests app` / `git clean -fd tests app`. For `phpunit.xml` and
|
||||
`composer.lock`, copy aside and copy back, verifying with `shasum`.
|
||||
4. **zsh does not word-split unquoted parameters.** A `$PEST` string containing a space becomes one
|
||||
command name. Route every invocation through `eval` (the `pest()` helper below does this).
|
||||
5. Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||
6. **The sentinel technique must not falsify `assertions` on zero-assertion tests.** A
|
||||
risky/skipped/incomplete status is *derived* from "performed no assertions", so patching those to
|
||||
`42` rewrites the status on replay and destroys the discriminator — it shows up as a phantom
|
||||
`status 5→0` defect. The `sentinel.php` below only patches `assertions` where it is already
|
||||
non-zero. With that, a full replay gives a clean `rewritten=0`.
|
||||
7. Restore branch state after every L row. Several rows rename or detach; leaving a stray `main` key
|
||||
in the graph is exactly what produced phase two's false H6/H7 passes.
|
||||
|
||||
### 4.2 Reference numbers
|
||||
|
||||
The playground already carries the phase-two fixtures at commit `fb2e77e` — **do not rebuild them.**
|
||||
A healthy sequential `--tia` graph is:
|
||||
|
||||
> **`files=27`, 10 edge keys (one per test file), self-edge on all 10, `n=25` results.**
|
||||
|
||||
`sha` will differ once Nuno commits the vendor sync — re-derive it once and use it throughout. The
|
||||
`files`/`edges`/`n` numbers hold as long as no test fixture changes. Suite shape: 10 test files, 25
|
||||
tests, including six deliberate status fixtures (skipped, todo, incomplete, risky, warning,
|
||||
deprecation), a 3-row dataset, a `smoke` group, an env-driven flaky test (green unless
|
||||
`FLAKY_FAIL=1`), and the annotation set (`covers`/`note`/`flaky`/`issue`/`pr`/`ticket`/`assignee`).
|
||||
|
||||
**Re-run H6 and H7 from a cold graph** (`rm -rf` the graph dir, record on `master` only, *then*
|
||||
branch/detach). Their phase-two results are void.
|
||||
|
||||
### 4.3 Harness
|
||||
|
||||
Write these to your scratchpad. `$SP` is your own scratchpad dir.
|
||||
|
||||
<details>
|
||||
<summary><code>lib.sh</code></summary>
|
||||
|
||||
```bash
|
||||
#!/bin/zsh
|
||||
export PAO_DISABLE=1
|
||||
PG=/Users/nunomaduro/Work/projects/playground/laravel
|
||||
SP="<your scratchpad>"
|
||||
PHPBIN="php85"
|
||||
PEST="$PHPBIN $PG/vendor/bin/pest"
|
||||
cd "$PG" || exit 1
|
||||
pest() { eval "$PEST $*"; } # zsh: no word-splitting, must eval
|
||||
GRAPHDIR="$(pest --baseline)"
|
||||
GRAPH="$GRAPHDIR/graph.json"
|
||||
mkdir -p "$SP/bin" && ln -sf "$(command -v php85)" "$SP/bin/php"
|
||||
export PATH="$SP/bin:$PATH" # --shard spawns bare `php`
|
||||
reset_tree() { git checkout -- tests app 2>/dev/null; git clean -qfd tests app 2>/dev/null; }
|
||||
seed() { rm -rf "$GRAPHDIR"; pest --tia >/dev/null 2>&1; $PHPBIN "$SP/sentinel.php" "$GRAPH" >/dev/null; cp "$GRAPH" "$SP/before.json"; }
|
||||
snap() { cp "$GRAPH" "$SP/before.json"; }
|
||||
delta() { $PHPBIN "$SP/cmp.php" "$SP/before.json" "$GRAPH"; }
|
||||
keys() { $PHPBIN -r '$g=json_decode(file_get_contents($argv[1]),true);echo "[".implode(",",array_keys($g["baselines"]??[]))."]";' "$GRAPH"; }
|
||||
tally() { sed -E $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' <"$SP/out.txt" | grep -E 'Tests:' | sed -E 's/^ +//;s/Tests: +//'; }
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>sentinel.php</code> — the write discriminator</summary>
|
||||
|
||||
```php
|
||||
<?php // sentinel.php <graph.json>
|
||||
$p = $argv[1];
|
||||
$g = json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR);
|
||||
$n = 0;
|
||||
foreach ($g['baselines'] ?? [] as $br => $b) {
|
||||
foreach (array_keys($b['results'] ?? []) as $id) {
|
||||
$g['baselines'][$br]['results'][$id]['time'] = 9.999;
|
||||
// Only falsify a non-zero assertion count: risky/skipped/incomplete are
|
||||
// DERIVED from "performed no assertions", so patching those to 42 would
|
||||
// rewrite the status on replay and destroy the discriminator.
|
||||
if ((int) ($b['results'][$id]['assertions'] ?? 0) > 0) {
|
||||
$g['baselines'][$br]['results'][$id]['assertions'] = 42;
|
||||
}
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
file_put_contents($p, json_encode($g, JSON_THROW_ON_ERROR));
|
||||
echo "sentinelled $n results\n";
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>oneline.php</code> — one compact tier verdict per row</summary>
|
||||
|
||||
```php
|
||||
<?php // oneline.php <before.json> <after.json>
|
||||
function load(string $p): ?array {
|
||||
return is_file($p) ? json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR) : null;
|
||||
}
|
||||
$a = load($argv[1]); $b = load($argv[2]);
|
||||
if ($a === null || $b === null) { echo 'GRAPH '.($b === null ? 'DELETED' : 'CREATED'); exit; }
|
||||
function edgeSets(array $g): array {
|
||||
$out = [];
|
||||
foreach ($g['edges'] ?? [] as $t => $ids) { $s = array_map(fn($i) => $g['files'][$i] ?? "?$i", (array) $ids); sort($s); $out[$t] = $s; }
|
||||
ksort($out); return $out;
|
||||
}
|
||||
$moved = [];
|
||||
if (edgeSets($a) !== edgeSets($b)) $moved[] = 'edges';
|
||||
if (($a['files'] ?? []) !== ($b['files'] ?? [])) $moved[] = 'files';
|
||||
if (($a['fingerprint'] ?? null) !== ($b['fingerprint'] ?? null)) $moved[] = 'fingerprint';
|
||||
$brA = array_keys($a['baselines'] ?? []); $brB = array_keys($b['baselines'] ?? []);
|
||||
if ($brA !== $brB) $moved[] = 'branchkeys('.implode('|', $brA).'->'.implode('|', $brB).')';
|
||||
$add = $rem = $wr = 0; $shaMoved = $treeMoved = false;
|
||||
foreach ($brB as $br) {
|
||||
$ra = $a['baselines'][$br]['results'] ?? []; $rb = $b['baselines'][$br]['results'] ?? [];
|
||||
if (($a['baselines'][$br]['sha'] ?? null) !== ($b['baselines'][$br]['sha'] ?? null)) $shaMoved = true;
|
||||
if (($a['baselines'][$br]['tree'] ?? null) !== ($b['baselines'][$br]['tree'] ?? null)) $treeMoved = true;
|
||||
$add += count(array_diff(array_keys($rb), array_keys($ra)));
|
||||
$rem += count(array_diff(array_keys($ra), array_keys($rb)));
|
||||
foreach ($ra as $id => $x) {
|
||||
if (! isset($rb[$id])) continue;
|
||||
foreach (['status','time','assertions','message'] as $f) {
|
||||
if (($x[$f] ?? null) !== ($rb[$id][$f] ?? null)) { $wr++; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
$n = 0; foreach ($brB as $br) $n = max($n, count($b['baselines'][$br]['results'] ?? []));
|
||||
printf('n=%d w=%-2d +%d -%d %s%s%s', $n, $wr, $add, $rem,
|
||||
$moved === [] ? 'struct:ok' : 'STRUCT:'.implode(',', $moved),
|
||||
$shaMoved ? ' sha:CHANGED' : '', $treeMoved ? ' tree:chg' : '');
|
||||
```
|
||||
</details>
|
||||
|
||||
`cmp.php` (verbose per-entry version of the same) and `summarise.php` / `edgediff.php` are in
|
||||
`PLAN_PHASE_TWO.md` §"Graph summariser" — reuse them for drill-downs. Reading the verdict:
|
||||
|
||||
- `w=` — entries actually **written**. Under sentinel patching this is the only reliable way to tell
|
||||
"wrote identical values" from "wrote nothing". A full replay must give `w=0`.
|
||||
- `struct:ok` + `+0 -0` — no prune, no edges/files/fingerprint movement. Required by RESULTS-ONLY.
|
||||
- `STRUCT:branchkeys(...)` — a new baseline key appeared. For section L this is the headline signal.
|
||||
|
||||
### 4.4 Section L — new rows for this fix
|
||||
|
||||
Tiers, unchanged from phase two: **COMPLETE** may change everything · **RESULTS-ONLY (RO)** may
|
||||
change only `baselines[<branch>].results` for tests that ran, and must never remove an entry, add a
|
||||
result for a test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||
**HARD-SUPPRESSED** may change nothing.
|
||||
|
||||
Every L row starts from a **cold graph recorded on the named default branch only** — verify
|
||||
`keys=[<default>]` before branching. Restore branch state afterwards.
|
||||
|
||||
| # | Case | Target outcome |
|
||||
|---|---|---|
|
||||
| L1 | default `master`, record, `git switch -c feature-x`, `pest --tia`, zero changes | **all 25 replayed** (`w=0`), not `25 uncached`. The headline fix. |
|
||||
| L2 | as L1 but default `main` | still all replayed — regression guard, this already worked |
|
||||
| L3 | default `trunk`, then `develop` | replayed for both; the fix must not special-case two names |
|
||||
| L4 | L1, then a *second* new branch `feature-y` | replayed too — the toll must not return per branch |
|
||||
| L5 | L1 then edit `app/Services/Calculator.php` on `feature-x` | narrows to the 2 affected files (`CalculatorTest` + `AnnotationsTest`, which `covers` it); the other 17 replay |
|
||||
| L6 | L1 with `--tia --filtered` | filtered mode reads the fallback too (`testFilesToRerun`, `hasUnlocatedTestsToRerun`) → `No affected tests found`, zero delta |
|
||||
| L7 | L1 with `--tia --parallel` | fallback works in workers as well as the parent |
|
||||
| L8 | L1, then `pest --tia` twice on `feature-x` | idempotent; second run also `w=0` |
|
||||
| L9 | detached HEAD on a `master`-only graph | replays, and **no `main` key minted** — `keys` stays `[master]`. Bug (b). |
|
||||
| L10 | `pest()->tia()->defaultBranch('master')` in `tests/Pest.php`, repo default renamed away | config wins over autodetect |
|
||||
| L11 | config set to a nonexistent branch (`defaultBranch('nope')`) | degrades to a full run; no crash, no phantom key |
|
||||
| L12 | no remote at all (`git remote remove origin` if present) | still resolves (via `init.defaultBranch`) or degrades safely — must not throw |
|
||||
| L13 | branch name with a slash (`feature/x/y`) | replayed; no key-splitting bugs |
|
||||
| L14 | L1, then confirm writes | `feature-x` gets its **own** key; the `master` key is **not** written to (fallback is read-only) |
|
||||
| L15 | git worktree on a new branch (the issue's scenario) | replays from the default-branch baseline |
|
||||
| L16 | non-git dir, `pest --tia` | still `MissingDependency: The feature "Tia mode" requires "git".` — the soft resolver must not swallow it |
|
||||
| L17 | non-git dir, plain `pest` | runs normally; no baseline dir created |
|
||||
| L18 | count `git` subprocesses during one `--tia` run | default-branch resolution is cached, not one call per test. Probe by shimming `git` on `PATH` to a logging wrapper. |
|
||||
|
||||
L10–L11 need a `tests/Pest.php` edit — that file is tracked and **outside** the `tests app` reset
|
||||
scope in practice (it lives in `tests/`, so `git checkout -- tests` does restore it; verify with
|
||||
`git status` after).
|
||||
|
||||
For L16/L17, build a throwaway non-git project — and note the trap that burned phase two: a
|
||||
**symlinked** `vendor` makes Pest resolve the project root back to the playground (identical baseline
|
||||
hash), silently invalidating the test. Use a hardlinked copy:
|
||||
|
||||
```bash
|
||||
NG="$SP/nogit"; rm -rf "$NG"; mkdir -p "$NG"
|
||||
cp -R composer.json composer.lock phpunit.xml artisan tests app bootstrap config routes resources storage "$NG/"
|
||||
[ -f .env ] && cp .env "$NG/"
|
||||
cp -Rl vendor "$NG/vendor" || cp -R vendor "$NG/vendor"
|
||||
```
|
||||
|
||||
Confirm the baseline path differs (`nogit-<hash>`, not `laravel-4a455a95622ac0ec`), and delete both
|
||||
the temp project and its `~/.pest/tia/nogit-*` dir afterwards.
|
||||
|
||||
### 4.5 Re-run the phase-two matrix (A–K, 156 rows)
|
||||
|
||||
Re-run every row of `PLAN_PHASE_TWO.md` Part 2 against the fixed build. No row's status is trusted
|
||||
until re-measured — the fix touches `Graph`'s read path, which nearly every row exercises. Sections
|
||||
**A, B, H, I** are the load-bearing ones here (H is branch-key resolution; I is filtered mode; both
|
||||
consume the changed methods directly). **H6 and H7 must be re-derived from a cold graph** (Part 1.4).
|
||||
|
||||
Most rows batch cheaply — phase two ran C1–C20 in one call at roughly one line of output each. Use
|
||||
`oneline.php` for the sweep and `cmp.php` only to drill into anomalies.
|
||||
|
||||
### 4.6 Known pre-existing failures — do not report as regressions
|
||||
|
||||
| item | status |
|
||||
|---|---|
|
||||
| **G4 / G4b** — parallel replay clobbers cached `time` on all non-executed tests. `mergeWorkerReplayPartials()` takes `$result['time']` verbatim at `Tia.php:1451`, never routing through `resultTime()` as the sequential sites (1681, 1762) do. Assertions survive because workers replay those themselves. | Pre-existing (pre-fix had no preservation at all), **out of scope for phase three**. Report it as still-present; do not fix it unless Nuno asks. |
|
||||
| **C19** — `--tia --uses=…` cannot be fixtured. TIA hard-errors on PHPUnit classes (`EnsureTiaIsRunningPestTestsOnly`), and Pest has no chainable `->uses()`. | **Expected behaviour per Nuno.** Verify the tier (`w=0`, RO, notice) and move on. Not a defect. |
|
||||
| **J11** — `--repeat` is not a Pest option (`Unknown option "--repeat"`). | **Don't care per Nuno.** Mark SKIP. |
|
||||
| **J10** — `--random-order-seed` alone exits 1 with a WARN. Identical without `--tia`. | Pre-existing Pest behaviour, unrelated. Tier still holds. |
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Reporting
|
||||
|
||||
Per row: **tier respected (yes/no)**, the **graph delta** under sentinel patching, and for any failure
|
||||
the **pre-fix contrast** so a regression is told apart from a pre-existing defect. For a pre-fix
|
||||
contrast you need `db70017c` (or `bfd5b756` for "before phase three") files swapped into the
|
||||
playground's `vendor/` — that is a sync, so **ask Nuno first** and always restore afterwards.
|
||||
|
||||
State which pest commit produced the numbers. Close with:
|
||||
|
||||
1. Whether L1–L18 all pass (the fix works).
|
||||
2. Whether A–K regressed anywhere relative to phase two's 154/156.
|
||||
3. The D1–D4 decisions as implemented.
|
||||
4. Anything still open — including G4, which will still be failing.
|
||||
|
||||
Leave the playground on `master` with only the four user-modified files dirty, no stray branches, and
|
||||
no leftover `~/.pest/tia/*` dirs beyond `laravel-4a455a95622ac0ec`.
|
||||
@@ -0,0 +1,466 @@
|
||||
# TIA write-tier conformance — phase two
|
||||
|
||||
## Your task
|
||||
|
||||
**Re-run all 156 rows of the matrix in Part 2 from scratch, against the playground app** at
|
||||
`/Users/nunomaduro/Work/projects/playground/laravel`. Every row, including the ones already marked
|
||||
VERIFIED or PASS — the point of phase two is that no row's status is trusted until it has been
|
||||
re-measured against the current code. The `Phase 1` column is prior evidence and a hint at what to
|
||||
watch, never a reason to skip a row.
|
||||
|
||||
These are **not** the `pestphp/pest` repo's own tests (`composer test`) — do not run those. Each row is
|
||||
a `pest` invocation against the playground's suite, followed by a diff of the TIA graph it wrote.
|
||||
|
||||
Report, per row: tier respected (yes/no), the graph delta under sentinel patching, and — for any
|
||||
failure — the pre-fix contrast so a regression is told apart from a pre-existing defect. Do **not**
|
||||
stop at reading this file or summarising it; the deliverable is executed results.
|
||||
|
||||
Work in this order: **Part 1 (environment traps) → Part 1b (build the fixtures — the playground has
|
||||
none of them) → Part 2 (the matrix) → Part 3 (priorities)**. The matrix is too large for one context;
|
||||
take it one lettered section at a time and report as you finish each. Sections A, B, G, I and K are
|
||||
the load-bearing ones — do those first if you run short.
|
||||
|
||||
Phase one implemented `PLAN.md` Part 1 items 1–4 plus three §5 items. This file turns the matrix into
|
||||
a conformance check rather than a bug list.
|
||||
|
||||
Code under test: `pestphp/pest` at `/Users/nunomaduro/Work/projects/pestphp/pest`, branch
|
||||
`fix/tia-filtered`, commit **`bfd5b756`** or later. Verify with
|
||||
`grep -c 'recordsEdgesInWorkers\|recordsEdges' src/Plugins/Tia.php` → at least 3 hits. Pre-fix
|
||||
baseline for every contrast is **`db70017c`**.
|
||||
|
||||
---
|
||||
|
||||
## Part 0 — What phase one changed
|
||||
|
||||
| # | Change | Files |
|
||||
|---|---|---|
|
||||
| 1 | `hasUnlocatedTestsToRerun()` stats the file, so a deleted test file is "unlocated" | `src/Plugins/Tia/Graph.php` |
|
||||
| 2 | `enterReplayMode()` uses `activateLinkTracking()` under piggyback coverage | `src/Plugins/Tia.php` |
|
||||
| 3 | `enterReplayMode()` stamps `TIA_PIGGYBACK_COVERAGE` for workers | `src/Plugins/Tia.php` |
|
||||
| 4 | `replaceEdges(…, keepExisting:)` — piggyback edges seed empty sets, never overwrite populated ones | `Graph.php`, `Tia.php` |
|
||||
| 5 | `renderFreshGraph()` stops claiming "fresh graph" when the graph is kept; reason reworded to `recording a coverage baseline` | `Tia.php` |
|
||||
| 6 | `COVERAGE_REPORT_FLAGS` + `coverageReportActive()` union over `originalArguments`; new `pestCoverageActive()` keeps the coverage-cache marker/hijack on Pest's own `--coverage` | `Tia.php` |
|
||||
| 7 | `Tia::recordsEdgesInWorkers()` + `WrapperRunner::handleTia()` inject `-d pcov.directory=<root>` into worker argv | `Tia.php`, `src/Plugins/Parallel/Paratest/WrapperRunner.php` |
|
||||
| 8 | Sequential record runs announce structural drift via `renderFreshGraph()` | `Tia.php` |
|
||||
| 9 | `Graph::getTime()` + `cachedTimeByTestId` + `resultTime()` preserve replayed durations; edge-less write guard is now `$recordsEdges = $complete && ($markKnownTestFiles \|\| $this->recordingActive)` | `Graph.php`, `Tia.php` |
|
||||
|
||||
Deliberately **not** done: `PLAN.md` §5 SIGINT propagation, §5 warning/deprecation `status=0`
|
||||
mapping, and all of §6 (playground annotation fixtures). The vacuous C rows below stay vacuous.
|
||||
|
||||
### Target-outcome changes this forces
|
||||
|
||||
Two rows in the original matrix asserted the **old**, buggy behaviour. Their targets are updated
|
||||
below — do not report them as regressions:
|
||||
|
||||
- **B10** was "`time` differs; statuses stable". Change 9 means replayed entries now **keep** their
|
||||
recorded `time`. New target: `time` differs only for tests that actually executed.
|
||||
- **B12** gains an edge-preservation assertion it never had (see K1).
|
||||
|
||||
### One known-failing repo test
|
||||
|
||||
`tests/Unit/Plugins/Tia/Graph.php:69-76` asserts `hasUnlocatedTestsToRerun('main')` is `false` for
|
||||
`tests/Feature/FooTest.php` under `new Graph(sys_get_temp_dir())` — a path that does not exist. That
|
||||
assertion encodes the I9 bug and **will fail** under change 1. It needs re-pointing at a root/file
|
||||
that exists (e.g. `dirname(__DIR__, 4)` + `'tests/Unit/Plugins/Tia/Graph.php'`). Left untouched by
|
||||
request; it is a repo-test matter, not a playground one.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Environment, and the traps in it
|
||||
|
||||
**Playground:** `/Users/nunomaduro/Work/projects/playground/laravel`
|
||||
|
||||
```bash
|
||||
cd /Users/nunomaduro/Work/projects/playground/laravel
|
||||
GRAPH="$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)/graph.json" # ~/.pest/tia/laravel-4a455a95622ac0ec
|
||||
```
|
||||
|
||||
1. **Every** pest invocation needs `PAO_DISABLE=1` — the app has `laravel/pao`, which emits JSON
|
||||
when it detects an agent.
|
||||
2. **Never run `git checkout .` or `git checkout -- composer.lock` in the playground.** It has four
|
||||
pre-existing user-modified files — `AGENTS.md`, `CLAUDE.md`, `composer.json`, `composer.lock` —
|
||||
that a blanket reset would destroy. `PLAN.md`'s "reset ritual" is unsafe as written. Scope resets
|
||||
to what you touched: `git checkout -- tests app` / `git clean -fd tests app`, and for
|
||||
`composer.lock` (needed for the drift rows) **copy it aside and copy it back**, verifying with
|
||||
`shasum`.
|
||||
3. **`vendor/pestphp/pest` is a dist copy, not a symlink.** Composer installed
|
||||
`dev-fix/tia-filtered as 5.2.0`, so edits in the pest repo do **not** reach the playground.
|
||||
|
||||
**Tell Nuno whenever a sync is needed to move forward — do not sync silently.** Say what is stale
|
||||
and what the sync would be, then wait. This includes temporarily swapping in `db70017c` files for a
|
||||
before/after contrast. When you report any playground result, state which commit produced it.
|
||||
The sync itself, once he agrees:
|
||||
```bash
|
||||
PEST=/Users/nunomaduro/Work/projects/pestphp/pest
|
||||
V=/Users/nunomaduro/Work/projects/playground/laravel/vendor/pestphp/pest
|
||||
for f in src/Plugins/Tia.php src/Plugins/Tia/Graph.php \
|
||||
src/Plugins/Parallel/Paratest/WrapperRunner.php; do cp "$PEST/$f" "$V/$f"; done
|
||||
```
|
||||
Verify with `grep -c recordsEdgesInWorkers "$V/src/Plugins/Tia.php"` → `1`. **Always restore the
|
||||
current version before continuing** after a pre-fix contrast.
|
||||
4. **Coverage driver:** pcov only, no xdebug. `ini_get('pcov.directory')` is `''` by default — that
|
||||
emptiness is the entire mechanism behind G12.
|
||||
5. **Suite shape as found:** 7 tests in 5 files — `tests/Unit/{CalculatorTest,ExampleTest,GreeterTest,
|
||||
TrioTest}.php`, `tests/Feature/ExampleTest.php`. A healthy sequential graph is **`files=22`,
|
||||
5 edge keys, self-edge on every test** (`PLAN.md`'s `files=25` is stale). Pre-fix parallel gives
|
||||
`files=4` with zero self-edges. **These numbers shift the moment you add the Part 1b fixtures** —
|
||||
re-derive them once, after the fixtures land, and use the new numbers throughout. The invariants
|
||||
that do *not* shift: sequential and parallel must agree, and every test must have a self-edge.
|
||||
6. **Sentinel patching is the only reliable discriminator** between "wrote identical values" and
|
||||
"wrote nothing": rewrite every cached result to `time=9.999 assertions=42`, snapshot, run the
|
||||
case, diff. A canary test absent from `edges` is unreliable under `--filter` because it never
|
||||
matches the filter and so never runs.
|
||||
7. Seeding a cached failure needs `--fresh` (or an env-driven flaky fixture): `--tia` on a clean
|
||||
green tree replays rather than executes, so it can never cache a failure. Working recipe — break
|
||||
an assertion, `pest --tia --fresh`, then restore the source.
|
||||
8. Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||
9. The shell is zsh — build commands with arrays or `eval`; unquoted `$args` does not word-split.
|
||||
|
||||
### Graph summariser
|
||||
|
||||
Write this to a scratch path and use it for every diff.
|
||||
|
||||
```php
|
||||
<?php // summarise.php <graph.json> [label]
|
||||
$g = json_decode((string) file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
|
||||
$files = $g['files'] ?? []; $edges = $g['edges'] ?? [];
|
||||
echo ($argv[2] ?? $argv[1])."\n files=".count($files).' edges keys='.count($edges)."\n";
|
||||
$dirs = []; foreach ($files as $f) { $dirs[explode('/', (string) $f)[0]] = true; }
|
||||
ksort($dirs); echo ' file dirs: '.implode(', ', array_keys($dirs))."\n";
|
||||
ksort($edges);
|
||||
foreach ($edges as $test => $ids) {
|
||||
$self = 'no';
|
||||
foreach ((array) $ids as $id) { if (($files[$id] ?? null) === $test) { $self = 'YES'; break; } }
|
||||
echo sprintf(" %-42s n=%-3d self=%s\n", $test, count((array) $ids), $self);
|
||||
}
|
||||
foreach ($g['baselines'] ?? [] as $branch => $b) {
|
||||
$r = $b['results'] ?? [];
|
||||
echo " baseline[$branch]: n=".count($r).' sha='.substr((string) ($b['sha'] ?? '-'), 0, 7)."\n";
|
||||
foreach ($r as $id => $x) {
|
||||
echo sprintf(" %-58s status=%d time=%s asserts=%d file=%s\n", substr((string) $id, -58),
|
||||
$x['status'], $x['time'], $x['assertions'], $x['file'] ?? '-');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Normalised edge-set equality (for G12 / I5 / K1):
|
||||
|
||||
```php
|
||||
<?php // edgediff.php <a.json> <b.json>
|
||||
function edges(string $p): array {
|
||||
$g = json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR); $out = [];
|
||||
foreach ($g['edges'] as $t => $ids) { $s = array_map(fn ($i) => $g['files'][$i], $ids); sort($s); $out[$t] = $s; }
|
||||
ksort($out); return $out;
|
||||
}
|
||||
$a = edges($argv[1]); $b = edges($argv[2]);
|
||||
echo $a === $b ? "IDENTICAL edge sets\n" : "DIFFER\n";
|
||||
foreach ($a as $t => $s) {
|
||||
$m = array_diff($s, $b[$t] ?? []); $e = array_diff($b[$t] ?? [], $s);
|
||||
if ($m || $e) printf(" %s: -%d +%d\n", $t, count($m), count($e));
|
||||
}
|
||||
```
|
||||
|
||||
### Per-case loop
|
||||
|
||||
```bash
|
||||
git checkout -- tests app 2>/dev/null; git clean -qfd tests app
|
||||
rm -rf "$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)"
|
||||
PAO_DISABLE=1 ./vendor/bin/pest --tia >/dev/null 2>&1 # seed a healthy graph
|
||||
# sentinel-patch every result to time=9.999 assertions=42, snapshot, run the case, diff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1b — Fixtures you must build first
|
||||
|
||||
**The playground has none of the fixtures the matrix depends on.** Verified inventory: the only test
|
||||
files are `tests/Unit/{CalculatorTest,ExampleTest,GreeterTest,TrioTest}.php` and
|
||||
`tests/Feature/ExampleTest.php` — 7 plain tests, zero occurrences of `->group()`, `->only()`,
|
||||
`->skip()`, `->todo()`, `->note()`, `->flaky()`, `->covers()`, `->uses()`, `->issue()`, `->pr()`,
|
||||
`->ticket()`, `->assignee()`, datasets, or any `FLAKY_OK`-style env hook. `phpunit.xml` defines only
|
||||
the `Unit` and `Feature` testsuites.
|
||||
|
||||
So roughly 30 rows below cannot run as written until you create the fixtures. Build them **all up
|
||||
front, in one commit**, then re-derive the baseline graph shape once and use those numbers for the
|
||||
whole sweep — every fixture you add changes `files`, the `edges` key count and `n`, so adding them
|
||||
piecemeal invalidates earlier rows.
|
||||
|
||||
| Fixture to create | Rows that need it |
|
||||
|---|---|
|
||||
| a test with `->group('smoke')` | C7, C8, C9, C10 |
|
||||
| an env-driven flaky test (passes iff `FLAKY_OK=1`) | E1, E2, E3, E4 |
|
||||
| a `->skip()`ed test | E9, D8 |
|
||||
| a `->todo()` test | E10, C23, C39 |
|
||||
| a risky test (no assertions; pair with `--disallow-test-output`) | E11, D7 |
|
||||
| a test that triggers a PHPUnit warning | D6 |
|
||||
| a test calling `markTestIncomplete()` | D9 |
|
||||
| a test that triggers a deprecation | D10 |
|
||||
| a dataset test with ≥3 rows | E13, E14 |
|
||||
| `->only()` — added and removed per case, not left in | C31, C32, G7 |
|
||||
| `->covers(App\Services\Calculator::class)` | C18 |
|
||||
| `->uses(...)` / `UsesClass` annotation | C19 |
|
||||
| `->note(...)` | C24 |
|
||||
| `->flaky()` **annotation** (distinct from the env-driven flaky test above) | C25 |
|
||||
| `->issue(123)`, `->pr(1)`, `->ticket('X')`, `->assignee('X')` | C26, C27, C28, C37, C38 |
|
||||
|
||||
This is `PLAN.md` §6's "test-harness gaps to close before re-running", now itemised: without these,
|
||||
the listed rows' graph-invariant assertions match zero tests and **prove nothing** — they pass
|
||||
vacuously. Any row still marked "(vacuous)" in Part 2 is vacuous *only because* its fixture is
|
||||
missing; once you add the fixture, treat the row as unverified and make it load-bearing.
|
||||
|
||||
Rows needing an *action* rather than a fixture — breaking `trio one` for the D rows, an uncommitted
|
||||
test edit for `--dirty`, branch renames and a non-git dir for H5–H10, `--mutate` against
|
||||
`app/Services` for the F rows — are fine as written; `pestphp/pest-plugin-mutate` is installed.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Full case matrix
|
||||
|
||||
Tiers: **COMPLETE** may change everything · **RESULTS-ONLY** (RO) may change only
|
||||
`baselines[<branch>].results` for tests that ran, and must never remove an entry, add a result for a
|
||||
test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||
**HARD-SUPPRESSED** may change nothing.
|
||||
|
||||
Phase-1 column: **VERIFIED** = re-run against the fixed code in the phase-one session, pre-fix
|
||||
contrast captured · **PASS (sweep)** = passed in the original `db70017` sweep and *not* re-checked
|
||||
since the changes — these are the bulk of phase two's work · **SKIP** = not runnable as written.
|
||||
|
||||
### A — Setup & sanity
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| A1 | `composer show pestphp/pest` | version `dev-fix/tia-filtered` | PASS (sweep) |
|
||||
| A2 | `pest --baseline` | prints an existing dir; exit 0 | VERIFIED |
|
||||
| A3 | delete graph, `pest --tia` | graph.json created; one `edges` key per test file; one result per test | VERIFIED |
|
||||
| A4 | `git status` after reset ritual | clean *except the four user-modified files* (see trap 2) | VERIFIED |
|
||||
| A5 | `extension_loaded("pcov")` | `true` | VERIFIED |
|
||||
| A6 | delete graph, plain `pest` | no graph created | PASS (sweep) |
|
||||
| A7 | delete graph, `pest --filter=adds` | no graph created | PASS (sweep) |
|
||||
| A8 | two consecutive `pest --tia` | second replays everything; `sha` unchanged | VERIFIED |
|
||||
|
||||
### B — COMPLETE runs still write
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| B1 | `pest --tia` (clean) | replays; graph written; `sha` = `git rev-parse HEAD` | VERIFIED |
|
||||
| B2 | `pest --no-tia` | results refreshed; prune applied; `sha`/`tree`/`edges` unchanged | PASS (sweep) — recheck under change 9 |
|
||||
| B3 | `pest` (plain) | same as B2 | PASS (sweep) — recheck under change 9 |
|
||||
| B4 | `pest --tia --fresh` | graph purged and rebuilt; `edges` rebuilt; `files` repopulated | VERIFIED |
|
||||
| B5 | `pest --bail`, all green | COMPLETE — graph written, prune applied | PASS (sweep) |
|
||||
| B6 | edit `Calculator.php`, `pest --tia` | `CalculatorTest` re-runs, others replay; `tree` updated | PASS (sweep) |
|
||||
| B7 | delete `trio three`, `pest --tia` | its result pruned; `trio one`/`two` remain | PASS (sweep) |
|
||||
| B8 | delete `GreeterTest.php`, `pest --tia` | result and edges survive (missing-file prune is `--fresh`-only) | PASS (sweep) |
|
||||
| B9 | B8 then `pest --tia --fresh` | entry gone; `files` drops `Greeter.php` too | PASS (sweep) |
|
||||
| B10 | `pest --tia` twice | **UPDATED TARGET:** `time` differs only for tests that executed; replayed entries keep their recorded `time`; statuses stable | VERIFIED |
|
||||
| B11 | add a test file, `pest --tia` | new `edges` key **and** new result appear in the **same** run | VERIFIED (regression guard for change 9) |
|
||||
| B12 | `pest --tia --coverage` | completes; graph written; coverage report prints | VERIFIED |
|
||||
|
||||
### C — Selection narrowing → RESULTS-ONLY
|
||||
|
||||
All rows: RO invariants hold. "Notice" = `TIA does not apply to partial runs — running the selected tests directly.`
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| C1 | `pest --filter="adds numbers"` | RO; no notice | PASS (sweep) |
|
||||
| C2 | `pest --tia --filter="adds numbers"` | RO; notice | PASS (sweep) |
|
||||
| C3 | `pest --filter="trio one"` | RO; `trio two`/`three` byte-identical | PASS (sweep) |
|
||||
| C4 | `pest --filter="trio"` | RO; all three update; nothing else does | PASS (sweep) |
|
||||
| C5 | `pest --exclude-filter="Feature"` | RO; no notice; no prune | PASS (sweep) |
|
||||
| C6 | `pest --tia --exclude-filter="Feature"` | RO; notice | PASS (sweep) |
|
||||
| C7 | `pest --group=smoke` | RO; no notice | PASS (sweep) |
|
||||
| C8 | `pest --tia --group=smoke` | RO; notice | PASS (sweep) |
|
||||
| C9 | `pest --exclude-group=smoke` | RO; no notice; no prune | PASS (sweep) |
|
||||
| C10 | `pest --tia --exclude-group=smoke` | RO; notice | PASS (sweep) |
|
||||
| C11 | `pest tests/Unit` | RO; no notice; Feature entry not pruned | PASS (sweep) |
|
||||
| C12 | `pest --tia tests/Unit` | RO; notice | PASS (sweep) |
|
||||
| C13 | `pest tests/Unit/TrioTest.php` | RO; no notice | PASS (sweep) |
|
||||
| C14 | `pest --tia tests/Unit/TrioTest.php` | RO; notice | PASS (sweep) |
|
||||
| C15 | `pest --testsuite=Unit` | RO; no notice | PASS (sweep) |
|
||||
| C16 | `pest --tia --testsuite=Unit` | RO; notice | PASS (sweep) |
|
||||
| C17 | `pest --tia --exclude-testsuite=Feature` | RO; notice | PASS (sweep) |
|
||||
| C18 | `pest --tia --covers='App\Services\Calculator'` | RO; notice | PASS (sweep) |
|
||||
| C19 | `pest --tia --uses='App\Services\Calculator'` | RO; notice | PASS (vacuous — needs fixture) |
|
||||
| C20 | `pest --tia --test-suffix=Test.php` | RO even though all tests run; `edges`/`files`/`n` unchanged | PASS (sweep) |
|
||||
| C21 | `pest --dirty` with an uncommitted test edit | RO; no notice | PASS (sweep) |
|
||||
| C22 | `pest --tia --dirty` | RO; notice | PASS (sweep) |
|
||||
| C23 | `pest --tia --todos` | RO; notice | PASS (vacuous) |
|
||||
| C24 | `pest --tia --notes` | RO; notice | PASS (vacuous) |
|
||||
| C25 | `pest --tia --flaky` | RO; notice | PASS (vacuous) |
|
||||
| C26 | `pest --tia --issue=123` | RO; notice | PASS (vacuous) |
|
||||
| C27 | `pest --tia --pr=1` | RO; notice | PASS (vacuous) |
|
||||
| C28 | `pest --tia --pull-request=1` | RO; notice | PASS (vacuous) |
|
||||
| C29 | `pest --tia --shard=1/2` | RO; notice; no prune | PASS (sweep) |
|
||||
| C30 | `pest --tia --shard=2/2` | RO; notice; C29+C30 covers the suite; neither prunes | PASS (sweep) |
|
||||
| C31 | `->only()` on `trio one`, `pest --tia` | RO; no notice; siblings untouched | PASS (sweep) |
|
||||
| C32 | `->only()` on `trio one`, plain `pest` | RO; no notice | PASS (sweep) |
|
||||
| C33 | `pest --tia --filtered --filter=adds` | RO; notice; filtered yields to narrowing | PASS (sweep) |
|
||||
| C34 | `PEST_TIA=1 pest --filter=adds` | RO; notice | PASS (sweep) |
|
||||
| C35 | `PEST_TIA_FILTERED=1 pest --filter=adds` | RO; notice (regression guard) | PASS (sweep) |
|
||||
| C36 | `pest --filtered --filter=adds` | RO; notice | PASS (sweep) |
|
||||
| C37 | `pest --tia --ticket=X` | RO; notice. `--ticket` **is** a real option (`src/Plugins/Snapshot.php:83`) — it narrows legitimately | PASS (vacuous) |
|
||||
| C38 | `pest --tia --assignee=X` | RO; notice (`src/Plugins/Snapshot.php:81`) | PASS (vacuous) |
|
||||
| C39 | `pest --tia --todo` | RO; notice; matches nothing | PASS (vacuous) |
|
||||
|
||||
### D — Truncation → RESULTS-ONLY
|
||||
|
||||
D1–D5, D11–D13, D16 precondition: `trio one` broken. D6–D10 run against a green suite so the `--stop-on-*` trigger is the only narrowing.
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| D1 | `pest --bail` | RO; `trio one` = `status=7`; siblings survive unchanged | PASS (sweep) |
|
||||
| D2 | `pest --retry` | RO; siblings survive | PASS (sweep) |
|
||||
| D3 | `pest --stop-on-failure` | RO | PASS (sweep) |
|
||||
| D4 | `pest --stop-on-defect` | RO | PASS (sweep) |
|
||||
| D5 | `pest --stop-on-error` (no error occurs) | COMPLETE — nothing stopped it | PASS (sweep) |
|
||||
| D6 | `pest --stop-on-warning` | RO when it fires. Warning stored as `status=0`, not `6` | PASS (sweep) |
|
||||
| D7 | `pest --stop-on-risky --disallow-test-output` | RO; `status=5` | PASS (sweep) |
|
||||
| D8 | `pest --stop-on-skipped` | RO; `status=1` | PASS (sweep) |
|
||||
| D9 | `pest --stop-on-incomplete` | RO; `status=2` | PASS (sweep) |
|
||||
| D10 | `pest --stop-on-deprecation` | RO. Deprecation stored as `status=0`, not `4` | PASS (sweep) |
|
||||
| D11 | `pest --tia --bail` | RO | PASS (sweep) |
|
||||
| D12 | `pest --bail --filter=trio` | RO (narrowed *and* truncated) | PASS (sweep) |
|
||||
| D13 | `stopOnFailure="true"` in `phpunit.xml`, plain `pest` | RO — caught via `stoppedEarly()`, not flag matching | PASS (sweep) |
|
||||
| D14 | D13 config, green suite | COMPLETE | PASS (sweep) |
|
||||
| D15 | `pest --bail`, last test in run order fails | RO — over-conservative but safe; nothing was skipped | PASS (sweep) |
|
||||
| D16 | `pest --tia --filtered --bail`, broken affected test | RO | PASS (sweep) |
|
||||
|
||||
### E — Result merge semantics
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| E1 | cached `flaky`=7, `FLAKY_OK=1 pest --filter=flaky` | flips to `status=0`; all other results byte-identical | PASS (sweep) |
|
||||
| E2 | cached `flaky`=0, `pest --filter=flaky` (env unset) | flips to `status=7` | PASS (sweep) |
|
||||
| E3 | after E1, `pest --tia --filtered` clean | `No affected tests found`; zero graph delta | PASS (sweep) |
|
||||
| E4 | after E2, `pest --tia --filtered` | re-runs, `from 1 previously unsuccessful test` | PASS (sweep) |
|
||||
| E5 | any partial run | `assertions` and `time` update for the test that ran (it executed, so change 9 does not apply) | PASS (sweep) |
|
||||
| E6 | any partial run | `message` of untouched tests unchanged | PASS (sweep) |
|
||||
| E7 | two sequential partial runs on different tests | each updates only its own entry; both persist | PASS (sweep) |
|
||||
| E8 | `pest --filter="trio one"` | siblings keep exact `status`/`time`/`assertions`/`message` | PASS (sweep) |
|
||||
| E9 | partial run of a `->skip()`ed test | `status=1` | PASS (sweep) |
|
||||
| E10 | partial run of a `->todo()` test | `status=1`, `assertions=0`, `message="__TODO__"` | PASS (sweep) |
|
||||
| E11 | partial run of a risky test | `status=5` | PASS (sweep) |
|
||||
| E12 | any partial run | `fingerprint` byte-identical | PASS (sweep) |
|
||||
| E13 | dataset test, `--filter` matching one row | that row updates; other rows survive (the old prune bug) | PASS (sweep) |
|
||||
| E14 | dataset test, full `--tia` after deleting a row | the deleted row is pruned | PASS (sweep) |
|
||||
|
||||
### F — Guard rails
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| F1 | test absent from `edges`, `pest --filter="brand new"` | no result recorded | VERIFIED |
|
||||
| F2 | complete `pest --tia` first, then the same filter | result is recorded | VERIFIED |
|
||||
| F3 | `pest --mutate --path=app/Services --covered-only` | statuses/`edges`/`sha`/`tree`/`files` identical; only `time` may change | PASS (sweep) |
|
||||
| F4 | `pest --mutate --parallel --path=… --covered-only` | same (observed: zero delta) | PASS (sweep) — recheck under change 7 |
|
||||
| F5 | weakened test so a mutant survives, `pest --mutate` | no status change in the baseline | PASS (sweep) |
|
||||
| F6 | `pest --mutate` with the graph deleted | no graph created | PASS (sweep) |
|
||||
| F7 | mutation subprocess env | carries `PEST_MUTATION_TESTING`; `--mutate` stripped from argv | PASS (sweep) |
|
||||
| F8 | grep baseline after `--mutate` | no mutation-flavoured messages | PASS (sweep) |
|
||||
|
||||
### G — Parallel
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| G1 | `pest --tia --parallel` (clean) | COMPLETE — graph written; all 7 results present | VERIFIED |
|
||||
| G2 | `pest --tia --parallel --filter=adds` | RO | PASS (sweep) |
|
||||
| G3 | `pest --parallel --bail`, `trio one` broken | RO; siblings survive | PASS (sweep) |
|
||||
| G4 | `pest --tia --parallel --bail` | RO | PASS (sweep) |
|
||||
| G5 | `pest --tia --filtered --parallel` after a source edit | narrows to affected; writes | VERIFIED (see I5) |
|
||||
| G6 | `pest --tia --parallel` | worker results reach the parent baseline | VERIFIED |
|
||||
| G7 | `->only()` + `pest --tia --parallel` | RO | PASS (sweep) |
|
||||
| G8 | `pest --tia --parallel --shard=1/2` | RO | PASS (sweep) |
|
||||
| G9 | broken test in one worker, `pest --parallel --bail` | whole run is RO, not just that worker's slice | PASS (sweep) |
|
||||
| G10 | `pest --parallel --retry` | `InvalidOption`; graph untouched | PASS (sweep) |
|
||||
| G11 | `pest --tia --parallel --fresh` | graph rebuilt | VERIFIED |
|
||||
| G12 | `edges` after `pest --tia` vs `pest --tia --parallel --fresh` | **equivalent edge sets** — `files=22`, self-edge on all 5 tests, both | **VERIFIED (fixed)** — pre-fix: `files=4`, 0 self-edges |
|
||||
|
||||
### H — Baseline key / branch resolution
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| H1 | `pest --tia` on `master` | only a `master` key | VERIFIED |
|
||||
| H2 | plain `pest` on `master` | writes `master`, not `main` | PASS (sweep) |
|
||||
| H3 | `pest --bail` truncated on `master` | no `main` key minted | PASS (sweep) |
|
||||
| H4 | `pest --filter=adds` on `master` | no `main` key minted | PASS (sweep) |
|
||||
| H5 | `git branch -m main`, full `pest --tia` | single `main` key | PASS (sweep) |
|
||||
| H6 | `git checkout -b feature/x`, `pest --tia` | `feature/x` key appears; reads fall back to the existing baseline | PASS (sweep) |
|
||||
| H7 | detached HEAD, `pest --tia` | falls back to the `main` key for both reads and writes; no `HEAD` key | PASS (sweep) |
|
||||
| H8 | after every C and D case | no baseline key other than the real branch | PASS (sweep) |
|
||||
| H9 | non-git dir, `pest --tia` | `MissingDependency` — `The feature "Tia mode" requires "git".` | PASS (sweep) |
|
||||
| H10 | non-git dir, plain `pest` | runs normally; no baseline dir created | PASS (sweep) |
|
||||
|
||||
### I — Filtered mode
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| I1 | clean + green, `pest --tia --filtered` | `No affected tests found`; zero graph delta | PASS (sweep) |
|
||||
| I2 | edit `Calculator.php` | only `CalculatorTest` runs | VERIFIED |
|
||||
| I3 | cached failure, clean tree | re-runs it, `from 1 previously unsuccessful test` | PASS (sweep) |
|
||||
| I4 | I2 with sentinels | `tree` updated; exactly one entry rewritten; all others retained | PASS (sweep) |
|
||||
| I5 | `pest --tia --filtered --parallel` | same narrowing as I2 **and `edges` unchanged** | **VERIFIED (fixed)** — 1 affected test, edges identical |
|
||||
| I6 | `PEST_TIA_FILTERED=1 pest --tia` | identical delta to I4 | PASS (sweep) |
|
||||
| I7 | `pest --tia --filtered tests/Unit` | explicit path wins; filtered off; RO; notice | PASS (sweep) |
|
||||
| I8 | `pest --tia --filtered --coverage-text` | filtered mode disabled by an active coverage report → full suite runs | **VERIFIED (fixed)** — pre-fix: `No affected tests found` |
|
||||
| I9 | cached failure whose test file was deleted | WARN `Some cached tests due a re-run could not be located on disk` + `Running the full suite with replay instead of a filtered run` | **VERIFIED (fixed)** — pre-fix: `No tests found`, exit 0 green |
|
||||
| I10 | `pest --tia --filtered` with no baseline yet | records a baseline instead of filtering | PASS (sweep) |
|
||||
| I11 | edit a Blade view a Feature test renders | the Feature test is selected | PASS (sweep) |
|
||||
| I12 | edit `composer.lock` | fingerprint drift → full rebuild, **and the reason is printed sequentially**: `fresh graph (composer.lock changed)` | **VERIFIED (fixed)** — pre-fix: bare `Running in TIA mode.` |
|
||||
|
||||
### J — Interactions & regressions
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| J1 | `pest --tia --fresh --filter=adds` | graph **not** purged; RO | PASS (sweep) |
|
||||
| J2 | `pest --tia --refetch --filter=adds` | refetch not performed; RO | PASS (sweep) |
|
||||
| J3 | `pest --no-tia --filter=adds` | RO — narrowing wins over `--no-tia`; no notice | PASS (sweep) |
|
||||
| J4 | `pest --tia --no-tia` | TIA disabled; COMPLETE | PASS (sweep) |
|
||||
| J5 | `<groups><exclude><group>integration</group></exclude></groups>`, `pest --tia` | COMPLETE, no notice — an always-in-force filter applies to baseline runs too | PASS (sweep) |
|
||||
| J6 | `pest --tia --compact` | COMPLETE | PASS (sweep) |
|
||||
| J7 | `pest --tia -v` | COMPLETE | PASS (sweep) |
|
||||
| J8 | `pest --tia --profile` | COMPLETE | PASS (sweep) |
|
||||
| J9 | `pest --tia --order-by=random` | COMPLETE | PASS (sweep) |
|
||||
| J10 | `pest --tia --random-order-seed=1234` | COMPLETE | PASS (sweep) |
|
||||
| J11 | `pest --tia --repeat=2` | COMPLETE — repetition is not narrowing | **SKIP** — `--repeat` is not a Pest option |
|
||||
| J12 | `pest --filter=adds` twice | only the executed test's `time` differs; rest byte-identical | PASS (sweep) |
|
||||
| J13 | `pest --tia --min=50` | COMPLETE (silent no-op without `--coverage`) | PASS (sweep) |
|
||||
| J14 | SIGINT mid-suite | RO — an interrupted run is truncated | PASS (sweep) — signal still does not reach the re-exec'd child (not fixed) |
|
||||
| J15 | delete the graph, `pest --tia --filtered` | records a baseline rather than erroring | PASS (sweep) |
|
||||
| J16 | corrupt `graph.json`, `pest --tia` | recovers by rebuilding; no crash | PASS (sweep) |
|
||||
|
||||
### K — New rows for the phase-one fixes
|
||||
|
||||
These assert behaviour no original row covered. All were verified in phase one; re-run them as
|
||||
regression guards.
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| K1 | healthy `pest --tia` graph, then `pest --tia --coverage` | `edges` **byte-identical** — piggyback data may seed empty sets, never narrow populated ones; `files=22` stays `22` | **VERIFIED (fixed)** — pre-fix: `ExampleTest` 16→2 edges, self-edges lost |
|
||||
| K2 | same run's headline | `Experimental TIA mode enabled / recording a coverage baseline.` — no false `fresh graph` | **VERIFIED (fixed)** |
|
||||
| K3 | `pest --tia` ×3 on a clean tree | replayed entries keep their recorded `time` across all three | **VERIFIED (fixed)** — pre-fix: `0.053 → 0.001 → 0.001 → 0.001` |
|
||||
| K4 | add a test file, one `pest --tia` (graph exists, fingerprint matches → replay+refresh) | result **and** edges appear in that same run; `n` grows by 1 | **VERIFIED** — regression guard for change 9 |
|
||||
| K5 | add a test file, plain `pest --no-tia` | no result written for it; `n` unchanged; no edges | **VERIFIED (fixed)** — pre-fix: edge-less result inflated `n` |
|
||||
| K6 | `pest --tia --parallel` worker argv | carries `-d pcov.directory=<projectRoot>`; `PEST_TIA` unset; not re-exec'd | **VERIFIED** — probe `bin/worker.php` |
|
||||
| K7 | second `pest --tia --coverage` (cache primed → replay) | recorder uses link tracking only, does not clear PHPUnit's data mid-collection; coverage report intact | Not yet measured — **new work** |
|
||||
| K8 | `pest --tia --parallel --coverage` | workers read `TIA_PIGGYBACK_COVERAGE`; no widened pcov scope; report intact | Not yet measured — **new work** |
|
||||
| K9 | `pest --tia --filtered --coverage-html=<dir>` / `--coverage-clover=<file>` | filtered mode off, same as I8, for every flag in `COVERAGE_REPORT_FLAGS` | Not yet measured — **new work** |
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Priorities for phase two
|
||||
|
||||
0. **Part 1b fixtures** — nothing in C, D6–D10, or E9–E14 means anything until they exist.
|
||||
1. **K7, K8, K9** — the only rows never measured. K7/K8 exercise changes 2 and 3, which were
|
||||
reasoned about but not observed; K9 covers the seven `COVERAGE_REPORT_FLAGS` beyond
|
||||
`--coverage-text`.
|
||||
2. **B2, B3, F4** and all of **D** and **E** — change 9 touched the shared result-write path, and
|
||||
these are the rows that exercise it hardest. `$recordsEdges` is the thing to falsify: it must be
|
||||
false for every partial and every non-recording run.
|
||||
3. **F3–F8** — change 7 injects a `-d` into worker argv; `--mutate --parallel` is the one place that
|
||||
both spawns workers and must write nothing.
|
||||
4. **H1–H10** — untouched by these changes; cheapest bulk confirmation.
|
||||
5. Raw PHPUnit coverage flags print **no report at all** in Pest, with or without TIA
|
||||
(`--no-tia --coverage-text` is equally silent). Pre-existing, unrelated to change 6 — do not
|
||||
chase it as a regression, but it means I8/K9 can only assert the selection half.
|
||||
|
||||
Report per row: tier respected (yes/no), the graph delta under sentinel patching, and for any
|
||||
failure the pre-fix contrast (write `git show db70017c:<file>` into vendor, re-run, restore) so a
|
||||
regression is told apart from a pre-existing defect.
|
||||
@@ -1,19 +1,20 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
||||
<p align="center">
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
||||
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
|
||||
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
------
|
||||
|
||||
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
||||
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
|
||||
|
||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
||||
|
||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||
- Follow the creator Nuno Maduro:
|
||||
@@ -28,26 +29,13 @@
|
||||
|
||||
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
|
||||
|
||||
|
||||
### Platinum Sponsors
|
||||
|
||||
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
||||
|
||||
### Gold Sponsors
|
||||
|
||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||
|
||||
### Premium Sponsors
|
||||
|
||||
- [Zapiet](https://zapiet.com/?ref=pestphp)
|
||||
- [Load Forge](https://loadforge.com/?ref=pestphp)
|
||||
- [Route4Me](https://route4me.com/pt?ref=pestphp)
|
||||
- [Nerdify](https://getnerdify.com/?ref=pestphp)
|
||||
- [Akaunting](https://akaunting.com/?ref=pestphp)
|
||||
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
|
||||
- **[PhpStorm](https://jb.gg/nuno)**
|
||||
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||
|
||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||
|
||||
+3
-3
@@ -2,10 +2,10 @@
|
||||
|
||||
When releasing a new version of Pest there are some checks and updates that need to be done:
|
||||
|
||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
||||
> **For Pest v4 you should use the `4.x` branch instead.**
|
||||
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
|
||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||
- Run the tests locally using: `composer test`
|
||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Contracts\Restarter;
|
||||
use Pest\Kernel;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||
@@ -142,6 +144,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
// Get $rootPath based on $autoloadPath
|
||||
$rootPath = dirname($autoloadPath, 2);
|
||||
|
||||
$input = new ArgvInput;
|
||||
|
||||
$testSuite = TestSuite::getInstance(
|
||||
@@ -192,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
try {
|
||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||
|
||||
$container = Container::getInstance();
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
assert($restarter instanceof Restarter);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $originalArguments);
|
||||
}
|
||||
|
||||
$result = $kernel->handle($originalArguments, $arguments);
|
||||
|
||||
$kernel->terminate();
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, relative, extname, sep, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const PAGE_EXTENSIONS = new Set([
|
||||
'.vue', '.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 PAGE_DIR_CANDIDATES = [
|
||||
'resources/js/Pages',
|
||||
'resources/js/pages',
|
||||
'assets/js/Pages',
|
||||
'assets/js/pages',
|
||||
'assets/Pages',
|
||||
'assets/pages',
|
||||
]
|
||||
|
||||
async function loadRolldown() {
|
||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||
let path = null
|
||||
|
||||
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||
|
||||
if (path === null) {
|
||||
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||
// dependency of the vite package rather than a top-level install.
|
||||
try {
|
||||
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||
path = viteRequire.resolve('rolldown')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (path === null) return null
|
||||
|
||||
return await import(pathToFileURL(path).href)
|
||||
}
|
||||
|
||||
export function stripJsonComments(raw) {
|
||||
let out = ''
|
||||
let inString = false
|
||||
let quote = ''
|
||||
let inLine = false
|
||||
let inBlock = false
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const c = raw[i]
|
||||
const n = raw[i + 1]
|
||||
|
||||
if (inLine) {
|
||||
if (c === '\n') { inLine = false; out += c }
|
||||
continue
|
||||
}
|
||||
if (inBlock) {
|
||||
if (c === '*' && n === '/') { inBlock = false; i++ }
|
||||
continue
|
||||
}
|
||||
if (inString) {
|
||||
out += c
|
||||
if (c === '\\') { out += n ?? ''; i++; continue }
|
||||
if (c === quote) inString = false
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
|
||||
if (c === '/' && n === '/') { inLine = true; i++; continue }
|
||||
if (c === '/' && n === '*') { inBlock = true; i++; continue }
|
||||
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
|
||||
out += c
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
async function readJsonWithComments(path) {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
return JSON.parse(stripJsonComments(raw))
|
||||
}
|
||||
|
||||
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
let cfg
|
||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||
for (const [key, targets] of Object.entries(paths)) {
|
||||
if (!key.endsWith('/*')) continue
|
||||
const t0 = Array.isArray(targets) ? targets[0] : null
|
||||
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
|
||||
const aliasKey = key.slice(0, -2)
|
||||
if (alias[aliasKey] !== undefined) continue
|
||||
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
|
||||
}
|
||||
}
|
||||
return alias
|
||||
}
|
||||
|
||||
const VITE_CONFIG_FILES = [
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
'vite.config.cts',
|
||||
]
|
||||
|
||||
function resolveAliasTarget(projectRoot, target) {
|
||||
if (target.startsWith('/')) {
|
||||
// Vite resolves a leading slash against the project root, unless the
|
||||
// config author used a genuinely absolute path.
|
||||
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
||||
}
|
||||
|
||||
return resolve(projectRoot, target)
|
||||
}
|
||||
|
||||
async function usesLaravelVitePlugin(projectRoot) {
|
||||
const p = join(projectRoot, 'package.json')
|
||||
if (!existsSync(p)) return false
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
||||
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
let source = null
|
||||
|
||||
for (const name of VITE_CONFIG_FILES) {
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
try { source = await readFile(p, 'utf8') } catch { continue }
|
||||
break
|
||||
}
|
||||
|
||||
if (source !== null) {
|
||||
// The config is executable code we cannot import safely, so extract alias
|
||||
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
||||
// the value expression — covers `'@': '/resources/js'` as well as
|
||||
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
||||
// top-level comma; call arguments are kept via the paren group.
|
||||
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
||||
const key = m[1]
|
||||
if (alias[key] !== undefined) continue
|
||||
|
||||
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
||||
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
||||
if (!target || target.includes('*')) continue
|
||||
|
||||
alias[key] = resolveAliasTarget(projectRoot, target)
|
||||
}
|
||||
}
|
||||
|
||||
// laravel-vite-plugin registers '@' → resources/js by default.
|
||||
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
||||
alias['@'] = resolve(projectRoot, 'resources/js')
|
||||
}
|
||||
|
||||
return alias
|
||||
}
|
||||
|
||||
async function listPageFiles(pagesDir) {
|
||||
if (!existsSync(pagesDir)) return []
|
||||
|
||||
const out = []
|
||||
const walk = async (dir) => {
|
||||
let entries
|
||||
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
||||
for (const entry of entries) {
|
||||
const full = resolve(dir, entry.name)
|
||||
if (entry.isDirectory()) { await walk(full); continue }
|
||||
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
|
||||
}
|
||||
}
|
||||
|
||||
await walk(pagesDir)
|
||||
return out
|
||||
}
|
||||
|
||||
async function discoverPagesDir() {
|
||||
const override = process.env.TIA_VITE_PAGES_DIR
|
||||
if (override && override.length > 0) {
|
||||
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
for (const rel of PAGE_DIR_CANDIDATES) {
|
||||
const abs = resolve(PROJECT_ROOT, rel)
|
||||
if (!existsSync(abs)) continue
|
||||
const files = await listPageFiles(abs)
|
||||
if (files.length > 0) return abs
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function componentNameFor(pageAbs, pagesDir) {
|
||||
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
||||
const ext = extname(rel)
|
||||
return rel.slice(0, rel.length - ext.length)
|
||||
}
|
||||
|
||||
function isLocalSpecifier(source, aliasKeys) {
|
||||
if (source.startsWith('.') || source.startsWith('/')) return true
|
||||
for (const key of aliasKeys) {
|
||||
if (source === key || source.startsWith(key + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pagesDir = await discoverPagesDir()
|
||||
|
||||
if (pagesDir === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const pages = await listPageFiles(pagesDir)
|
||||
|
||||
if (pages.length === 0) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = await loadRolldown()
|
||||
|
||||
if (loaded === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const { rolldown } = loaded
|
||||
// The vite config is what the dev server actually resolves with — let it win.
|
||||
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
||||
const aliasKeys = Object.keys(alias)
|
||||
|
||||
const graph = new Map()
|
||||
|
||||
const collector = {
|
||||
name: 'pest-tia-collector',
|
||||
moduleParsed(info) {
|
||||
const id = info.id
|
||||
if (!id || id.startsWith('\0')) return
|
||||
const deps = new Set()
|
||||
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
graph.set(id, deps)
|
||||
},
|
||||
}
|
||||
|
||||
const externalBare = {
|
||||
name: 'pest-tia-external-bare',
|
||||
resolveId(source) {
|
||||
if (!source) return null
|
||||
if (isLocalSpecifier(source, aliasKeys)) return null
|
||||
return { id: source, external: true }
|
||||
},
|
||||
}
|
||||
|
||||
const assetStub = {
|
||||
name: 'pest-tia-asset-stub',
|
||||
load(id) {
|
||||
if (!id) return null
|
||||
if (ASSET_EXT_RE.test(id)) {
|
||||
return { code: 'export default null', moduleSideEffects: false }
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const input = Object.create(null)
|
||||
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
||||
|
||||
const bundle = await rolldown({
|
||||
input,
|
||||
cwd: PROJECT_ROOT,
|
||||
resolve: {
|
||||
alias,
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||
},
|
||||
transform: { jsx: 'preserve' },
|
||||
treeshake: false,
|
||||
plugins: [externalBare, assetStub, collector],
|
||||
logLevel: 'silent',
|
||||
onLog: () => {},
|
||||
})
|
||||
|
||||
try {
|
||||
await bundle.generate({ format: 'esm' })
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
|
||||
const reverse = new Map()
|
||||
const transitiveCache = new Map()
|
||||
|
||||
const computeTransitive = (id, stack) => {
|
||||
const cached = transitiveCache.get(id)
|
||||
if (cached) return cached
|
||||
if (stack.has(id)) return null
|
||||
|
||||
stack.add(id)
|
||||
const acc = new Set()
|
||||
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
||||
// that dependency's subtree and must not be memoized — the ancestor call
|
||||
// completes it for the current traversal, but a cached copy would leak the
|
||||
// incomplete set into other pages' traversals.
|
||||
let complete = true
|
||||
const deps = graph.get(id)
|
||||
if (deps) {
|
||||
for (const dep of deps) {
|
||||
if (!dep || dep.startsWith('\0')) continue
|
||||
if (dep.startsWith(PROJECT_ROOT)) {
|
||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||
acc.add(rel)
|
||||
}
|
||||
if (stack.has(dep)) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
const child = computeTransitive(dep, stack)
|
||||
if (child) for (const r of child) acc.add(r)
|
||||
if (!transitiveCache.has(dep)) complete = false
|
||||
}
|
||||
}
|
||||
stack.delete(id)
|
||||
if (complete) transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
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 keys = [...reverse.keys()].sort()
|
||||
for (const key of keys) {
|
||||
payload[key] = [...reverse.get(key)].sort()
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] !== undefined
|
||||
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
await main()
|
||||
} catch (err) {
|
||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Kernel;
|
||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
@@ -58,6 +59,15 @@ $bootPest = (static function (): void {
|
||||
}
|
||||
}
|
||||
|
||||
$container = Container::getInstance();
|
||||
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
|
||||
}
|
||||
|
||||
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
|
||||
$statusFile = fopen($getopt['status-file'], 'wb');
|
||||
assert(is_resource($statusFile));
|
||||
|
||||
+18
-15
@@ -17,20 +17,20 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"nunomaduro/collision": "^8.9.3",
|
||||
"php": "^8.4",
|
||||
"brianium/paratest": "^7.23.1",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^4.0.0",
|
||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||
"phpunit/phpunit": "^12.5.23",
|
||||
"symfony/process": "^7.4.8|^8.0.8"
|
||||
"pestphp/pest-plugin": "^5.0.0",
|
||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||
"phpunit/phpunit": "^13.2.6",
|
||||
"symfony/process": "^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.23",
|
||||
"phpunit/phpunit": ">13.2.6",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@@ -48,6 +48,7 @@
|
||||
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
||||
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
|
||||
"Tests\\": "tests/PHPUnit/"
|
||||
},
|
||||
"classmap": [
|
||||
@@ -58,11 +59,12 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"mrpunyapal/peststan": "^0.2.5",
|
||||
"pestphp/pest-dev-tools": "^4.1.0",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||
"psy/psysh": "^0.12.22"
|
||||
"pestphp/pest-dev-tools": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||
"psy/psysh": "^0.12.24"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
@@ -123,6 +125,7 @@
|
||||
"Pest\\Plugins\\Verbose",
|
||||
"Pest\\Plugins\\Version",
|
||||
"Pest\\Plugins\\Shard",
|
||||
"Pest\\Plugins\\Tia",
|
||||
"Pest\\Plugins\\Parallel"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2011 Brian Scaturro
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ParaTest\WrapperRunner;
|
||||
|
||||
use PHPUnit\TextUI\Output\Printer;
|
||||
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||
* instead of being treated as unexpected output.
|
||||
*/
|
||||
final readonly class ProgressPrinterOutput implements Printer
|
||||
{
|
||||
public function __construct(
|
||||
private Printer $progressPrinter,
|
||||
private Printer $outputPrinter,
|
||||
) {}
|
||||
|
||||
public function print(string $buffer): void
|
||||
{
|
||||
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||
if (
|
||||
$buffer === "\n"
|
||||
|| preg_match('/^ +$/', $buffer) === 1
|
||||
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($buffer) {
|
||||
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
||||
default => $this->outputPrinter->print($buffer),
|
||||
};
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->progressPrinter->flush();
|
||||
$this->outputPrinter->flush();
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
||||
(static function () use ($suiteClassFile) {
|
||||
try {
|
||||
include_once $suiteClassFile;
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
} catch (Throwable $e) {
|
||||
Panic::with($e);
|
||||
}
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
})();
|
||||
|
||||
$loadedClasses = array_values(
|
||||
|
||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||
* "T" and swallows the "S" that follows it.
|
||||
*/
|
||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if (str_contains($event->message(), '__TODO__')) {
|
||||
if ($event->message() === '__TODO__') {
|
||||
$this->printTodoItem();
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
$this->printer()->testSkipped();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
private function printTodoItem(): void
|
||||
{
|
||||
$mirror = new ReflectionClass($this->printer());
|
||||
$printerMirror = $mirror->getMethod('printProgress');
|
||||
$printerMirror->invoke($this->printer(), 'T');
|
||||
$printProgress = $mirror->getMethod('printProgress');
|
||||
$printProgress->invoke($this->printer(), 'T');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
- phpstan-pest-extension.neon
|
||||
- vendor/mrpunyapal/peststan/extension.neon
|
||||
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 7
|
||||
|
||||
+2
-1
@@ -18,8 +18,9 @@
|
||||
<directory suffix=".php">./tests</directory>
|
||||
<directory suffix=".php">./tests-external</directory>
|
||||
<exclude>./tests/.snapshots</exclude>
|
||||
<exclude>./tests/.tests</exclude>
|
||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||
<exclude>./tests/Fixtures/Suites</exclude>
|
||||
<exclude>./tests/Fixtures/Tia</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
|
||||
+41
@@ -2,22 +2,63 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||
use Pest\Rector\Set\PestSetList;
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
|
||||
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
|
||||
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSets([
|
||||
PestSetList::CODING_STYLE,
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
__DIR__.'/tests/Fixtures/Arch',
|
||||
// Fixture suites are pinned by the TeamCity / JUnit snapshots, down to
|
||||
// the line numbers — rewriting their source would break them.
|
||||
__DIR__.'/tests/Fixtures/Suites',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
NarrowObjectReturnTypeRector::class,
|
||||
RemoveParentDelegatingConstructorRector::class,
|
||||
RemoveDuplicatedReturnSelfDocblockRector::class,
|
||||
RemoveUselessUnionReturnDocblockRector::class,
|
||||
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
||||
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
||||
],
|
||||
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
||||
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
||||
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
||||
UseToMatchArrayRector::class,
|
||||
// Test fixtures rely on "unused" constructors, params and properties
|
||||
// (resolved via the container or read through reflection), so the
|
||||
// dead-code and return-type rules below must not touch the test suite.
|
||||
RemoveEmptyClassMethodRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
RemoveUnusedConstructorParamRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
RemoveUnusedPrivatePropertyRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
AddArrowFunctionReturnTypeRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# Pest Testing 5
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||
|
||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||
|
||||
@boostsnippet("Basic Pest Test Example", "php")
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
@boostsnippet("Pest Response Assertion", "php")
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
@boostsnippet("Pest Dataset Example", "php")
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
@endboostsnippet
|
||||
|
||||
## Pest 5 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Tia (Test Impact Analysis)
|
||||
|
||||
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||
|
||||
@boostsnippet("Tia Example", "shell")
|
||||
./vendor/bin/pest --parallel --tia
|
||||
@endboostsnippet
|
||||
|
||||
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||
|
||||
### New Validation Expectations
|
||||
|
||||
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||
|
||||
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||
expect('nuno@pestphp.com')->toBeEmail();
|
||||
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||
expect('192.168.1.1')->toBeIpAddress();
|
||||
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||
expect('example.com')->toBeHostname();
|
||||
expect('example.co.uk')->toBeDomain();
|
||||
expect('Zm9vYmFy')->toBeBase64();
|
||||
expect('deadbeef')->toBeHexadecimal();
|
||||
@endboostsnippet
|
||||
|
||||
### Time-Balanced Sharding
|
||||
|
||||
Distribute tests across CI shards by execution time rather than count:
|
||||
|
||||
@boostsnippet("Pest Sharding Example", "shell")
|
||||
./vendor/bin/pest --update-shards
|
||||
./vendor/bin/pest --shard=1/4
|
||||
@endboostsnippet
|
||||
|
||||
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
@boostsnippet("Pest Browser Test Example", "php")
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
@endboostsnippet
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
@boostsnippet("Architecture Test Example", "php")
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
@endboostsnippet
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||
@@ -5,6 +5,8 @@
|
||||
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
||||
'INFO' => ['blue', 'INFO'],
|
||||
'ERROR' => ['red', 'ERROR'],
|
||||
'WARN' => ['yellow', 'WARN'],
|
||||
'SUCCESS' => ['green', 'SUCCESS'],
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const array FILES = [
|
||||
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||
'Runner/Filter/NameFilterIterator.php',
|
||||
'Runner/ResultCache/DefaultResultCache.php',
|
||||
'Runner/TestSuiteLoader.php',
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Bootstrappers;
|
||||
|
||||
use Pest\Contracts\Bootstrapper;
|
||||
use PHPUnit\TextUI\Configuration\Builder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BootPhpUnitConfiguration implements Bootstrapper
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
(new Builder)->build(['pest']);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
||||
Subscribers\EnsureTiaStarts::class,
|
||||
Subscribers\EnsureTiaEnds::class,
|
||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
||||
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,16 +7,24 @@ namespace Pest\Concerns;
|
||||
use Closure;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Collectors;
|
||||
use Pest\Plugins\Tia\Enums\ReplayType;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\ExceptionTrace;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\Shell;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
use PHPUnit\Framework\Attributes\PostCondition;
|
||||
use PHPUnit\Framework\IncompleteTest;
|
||||
use PHPUnit\Framework\SkippedTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||
use ReflectionException;
|
||||
use ReflectionFunction;
|
||||
use ReflectionParameter;
|
||||
@@ -75,6 +83,17 @@ trait Testable
|
||||
*/
|
||||
public bool $__ran = false;
|
||||
|
||||
/**
|
||||
* The active replay mode for this test, set in `setUp()` and checked
|
||||
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -259,8 +278,40 @@ trait Testable
|
||||
self::$__latestIssues = $method->issues;
|
||||
self::$__latestPrs = $method->prs;
|
||||
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||
$replay = ReplayType::fromStatus($status);
|
||||
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
// Marks the replay before the branches below throw, so `tearDown`
|
||||
// short-circuits for every replayed result — the throwing branches
|
||||
// never reach `parent::setUp`, so no user hook may run after them.
|
||||
$this->__replay = $replay;
|
||||
|
||||
match ($replay) {
|
||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
|
||||
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = Container::getInstance()->get(Recorder::class);
|
||||
assert($recorder instanceof Recorder);
|
||||
|
||||
if ($recorder->isActive()) {
|
||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||
}
|
||||
|
||||
parent::setUp();
|
||||
|
||||
Collectors::armAll($recorder);
|
||||
|
||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||
|
||||
if ($this->__beforeEach instanceof Closure) {
|
||||
@@ -270,6 +321,13 @@ trait Testable
|
||||
$this->__callClosure($beforeEach, $arguments);
|
||||
}
|
||||
|
||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||
{
|
||||
$this->__replay = $replay;
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test case properties from TestSuite.
|
||||
*/
|
||||
@@ -302,6 +360,12 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__replay !== ReplayType::None) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||
|
||||
if ($this->__afterEach instanceof Closure) {
|
||||
@@ -327,6 +391,16 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
|
||||
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
||||
$this->expectNotToPerformAssertions();
|
||||
}
|
||||
|
||||
$this->addToAssertionCount($this->__replayAssertions);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments = $this->__resolveTestArguments($args);
|
||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||
|
||||
@@ -364,10 +438,15 @@ trait Testable
|
||||
unset($this->{$property});
|
||||
}
|
||||
|
||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
||||
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||
|
||||
if ($hasOutputExpectation) {
|
||||
if ($outputBuffer->hasExpectation()) {
|
||||
ob_clean();
|
||||
|
||||
Closure::bind(function (): void {
|
||||
$this->expectedString = null;
|
||||
$this->expectedRegularExpression = null;
|
||||
}, $outputBuffer, OutputBuffer::class)();
|
||||
}
|
||||
|
||||
$this->setUp();
|
||||
@@ -383,7 +462,9 @@ trait Testable
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||
|
||||
$expectedClass = $read('expectedException');
|
||||
|
||||
@@ -391,13 +472,19 @@ trait Testable
|
||||
return $e instanceof $expectedClass;
|
||||
}
|
||||
|
||||
$expectedMessage = $read('expectedExceptionMessage');
|
||||
$expectedMessage = $read('expectedMessage');
|
||||
|
||||
if ($expectedMessage !== null) {
|
||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedExceptionCode');
|
||||
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||
|
||||
if ($expectedMessageRegex !== null) {
|
||||
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedCode');
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
return $e->getCode() === $expectedCode;
|
||||
|
||||
+19
-3
@@ -33,7 +33,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function in(string ...$targets): UsesCall
|
||||
{
|
||||
return (new UsesCall($this->filename, []))->in(...$targets);
|
||||
return new UsesCall($this->filename, [])->in(...$targets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +60,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function group(string ...$groups): UsesCall
|
||||
{
|
||||
return (new UsesCall($this->filename, []))->group(...$groups);
|
||||
return new UsesCall($this->filename, [])->group(...$groups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function only(): void
|
||||
{
|
||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
||||
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,22 @@ final readonly class Configuration
|
||||
return new Browser\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the evals configuration.
|
||||
*/
|
||||
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
||||
{
|
||||
return new Evals\Configuration; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TIA (Test Impact Analysis) configuration.
|
||||
*/
|
||||
public function tia(): Plugins\Tia\Configuration
|
||||
{
|
||||
return new Plugins\Tia\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies calls to the uses method.
|
||||
*
|
||||
|
||||
@@ -59,12 +59,15 @@ final class Project
|
||||
/**
|
||||
* Sets the test project to GitLab.
|
||||
*/
|
||||
public function gitlab(string $project): self
|
||||
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
||||
{
|
||||
$this->issues = "https://gitlab.com/{$project}/issues/%s";
|
||||
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
|
||||
// Simple way to ensure only the host is used
|
||||
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
||||
|
||||
$this->assignees = 'https://gitlab.com/%s';
|
||||
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
||||
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
|
||||
|
||||
$this->assignees = "https://{$hostname}/%s";
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Restarter
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function maybeRestart(string $projectRoot, array $arguments): void;
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $name, string $scope)
|
||||
{
|
||||
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
||||
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
||||
*/
|
||||
public function __construct(string $name)
|
||||
{
|
||||
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class DatasetProviderError extends RuntimeException
|
||||
{
|
||||
public function __construct(Throwable $previous)
|
||||
{
|
||||
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
||||
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance for the given class name.
|
||||
*/
|
||||
public static function fromClassName(string $filename, string $className): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||
$filename,
|
||||
$className,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Exception instance for the given namespace.
|
||||
*/
|
||||
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||
$filename,
|
||||
$namespace,
|
||||
$part,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
||||
*/
|
||||
public function __construct(string $fileName, string $description)
|
||||
{
|
||||
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
||||
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
||||
*/
|
||||
public function __construct(string $testCaseClass)
|
||||
{
|
||||
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
||||
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $fileName)
|
||||
{
|
||||
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
||||
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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 TiaRequiresDefaultBranch extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Tia mode could not determine the default branch every other branch falls back to reading.',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode could not determine the default branch.',
|
||||
'',
|
||||
' It is the branch whose baseline every other branch falls back to reading, and',
|
||||
' nothing in this checkout names it: the repository has a remote, but no',
|
||||
' <fg=yellow>origin/HEAD</>, and no CI provider stated it either. Guessing would re-run the',
|
||||
' whole suite on every new branch while reporting it as a cache hit.',
|
||||
'',
|
||||
' Name the branch in <fg=yellow>tests/Pest.php</>:',
|
||||
'',
|
||||
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
||||
'',
|
||||
' Or let git answer, once per clone:',
|
||||
'',
|
||||
' <fg=yellow>git remote set-head origin --auto</>',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $className, string $filename)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||
$className,
|
||||
$filename,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
|
||||
'',
|
||||
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
|
||||
sprintf(' in <fg=gray>%s</>.', $this->file),
|
||||
'',
|
||||
' Convert it to a Pest test, or run without Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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 TiaRequiresRemote extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Tia mode requires a repository with a remote, so the default branch every other branch falls back to reading can be resolved.',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires a repository with a remote.',
|
||||
'',
|
||||
' Without one there is no way to tell which branch is the default — the branch',
|
||||
' whose baseline every other branch falls back to reading — so the first run on',
|
||||
' each new branch would silently re-run the whole suite.',
|
||||
'',
|
||||
' Add a remote, or name the branch yourself in <fg=yellow>tests/Pest.php</>:',
|
||||
'',
|
||||
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $subdirectoryPrefix)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||
$this->subdirectoryPrefix,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
|
||||
'',
|
||||
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
|
||||
'',
|
||||
' Give the project its own git repository to use Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+15
-17
@@ -112,7 +112,7 @@ final class Expectation
|
||||
if (function_exists('dump')) {
|
||||
dump($this->value, ...$arguments);
|
||||
} else {
|
||||
var_dump($this->value);
|
||||
var_dump($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -120,16 +120,22 @@ final class Expectation
|
||||
|
||||
/**
|
||||
* Dump the expectation value and end the script.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function dd(mixed ...$arguments): void
|
||||
public function dd(mixed ...$arguments): never
|
||||
{
|
||||
if (function_exists('dd')) {
|
||||
dd($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
var_dump($this->value);
|
||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||
ob_start();
|
||||
var_dump($this->value, ...$arguments);
|
||||
$output = ob_get_clean();
|
||||
|
||||
throw new ExpectationFailedException($output);
|
||||
}
|
||||
|
||||
var_dump($this->value, ...$arguments);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
@@ -238,7 +244,7 @@ final class Expectation
|
||||
if ($callbacks[$index] instanceof Closure) {
|
||||
$callbacks[$index](new self($value), new self($key));
|
||||
} else {
|
||||
(new self($value))->toEqual($callbacks[$index]);
|
||||
new self($value)->toEqual($callbacks[$index]);
|
||||
}
|
||||
|
||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||
@@ -915,15 +921,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||
"to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -1138,8 +1136,8 @@ final class Expectation
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||
&& $object->reflectionClass->isEnum()
|
||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
'to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
|
||||
* Dynamically calls methods on the class with the given arguments.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
||||
/**
|
||||
* Accesses properties in the value or in the expectation.
|
||||
*
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
{
|
||||
|
||||
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($traits): bool {
|
||||
foreach ($traits as $trait) {
|
||||
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||
"not to use traits '".implode("', '", $traits)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||
"not to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$exporter = Exporter::default();
|
||||
|
||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
||||
|
||||
throw new ExpectationFailedException(sprintf(
|
||||
'Expecting %s not %s %s.',
|
||||
$toString($this->original->value),
|
||||
$exporter->shortenedExport($this->original->value),
|
||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| ! $object->reflectionClass->isEnum()
|
||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
'not to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
||||
use Pest\Contracts\HasPrintableTestCaseName;
|
||||
use Pest\Evaluators\Attributes;
|
||||
use Pest\Exceptions\DatasetMissing;
|
||||
use Pest\Exceptions\InvalidTestClassName;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Exceptions\TestAlreadyExist;
|
||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||
@@ -94,7 +95,8 @@ final class TestCaseFactory
|
||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||
}
|
||||
|
||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
||||
$realpath = (string) realpath($filename);
|
||||
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||
|
||||
@@ -137,6 +139,16 @@ final class TestCaseFactory
|
||||
|
||||
if (trim($className) === '') {
|
||||
$className = 'InvalidTestName'.Str::random();
|
||||
} elseif (! Str::isValidClassName($className)) {
|
||||
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
||||
}
|
||||
|
||||
if ($this->namespace === null) {
|
||||
foreach ($partsFQN as $partFQN) {
|
||||
if (! Str::isValidIdentifier($partFQN)) {
|
||||
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->attributes = [
|
||||
@@ -149,6 +161,8 @@ final class TestCaseFactory
|
||||
|
||||
$attributesCode = Attributes::code($this->attributes);
|
||||
|
||||
$filenameLiteral = var_export($realpath, true);
|
||||
|
||||
$methodsCode = implode('', array_map(
|
||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||
$methods
|
||||
@@ -158,6 +172,7 @@ final class TestCaseFactory
|
||||
$classCode = <<<PHP
|
||||
namespace $namespace;
|
||||
|
||||
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||
use Pest\TestSuite as __PestTestSuite;
|
||||
|
||||
@@ -166,7 +181,7 @@ final class TestCaseFactory
|
||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||
$traitsCode
|
||||
|
||||
private static \$__filename = '$filename';
|
||||
public static \$__filename = $filenameLiteral;
|
||||
|
||||
$methodsCode
|
||||
}
|
||||
@@ -197,7 +212,7 @@ final class TestCaseFactory
|
||||
|
||||
if (
|
||||
$method->closure instanceof \Closure &&
|
||||
(new \ReflectionFunction($method->closure))->isStatic()
|
||||
new \ReflectionFunction($method->closure)->isStatic()
|
||||
) {
|
||||
|
||||
throw new TestClosureMustNotBeStatic($method);
|
||||
|
||||
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
|
||||
$attributesCode
|
||||
public function $methodName(...\$arguments)
|
||||
{
|
||||
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||
}
|
||||
|
||||
return \$this->__runTest(
|
||||
\$this->__test,
|
||||
...\$arguments,
|
||||
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
|
||||
|
||||
public static function $dataProviderName()
|
||||
{
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
try {
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
} catch (\Throwable \$throwable) {
|
||||
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||
}
|
||||
}
|
||||
|
||||
EOF;
|
||||
|
||||
+33
-9
@@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Kernel
|
||||
final class Kernel
|
||||
{
|
||||
/**
|
||||
* Either the kernel is terminated or not.
|
||||
*/
|
||||
private bool $terminated = false;
|
||||
|
||||
/**
|
||||
* The Kernel bootstrappers.
|
||||
*
|
||||
@@ -36,6 +41,8 @@ final readonly class Kernel
|
||||
*/
|
||||
private const array BOOTSTRAPPERS = [
|
||||
Bootstrappers\BootOverrides::class,
|
||||
Bootstrappers\BootPhpUnitConfiguration::class,
|
||||
Plugins\Tia\Bootstrapper::class,
|
||||
Bootstrappers\BootSubscribers::class,
|
||||
Bootstrappers\BootFiles::class,
|
||||
Bootstrappers\BootView::class,
|
||||
@@ -43,15 +50,22 @@ final readonly class Kernel
|
||||
Bootstrappers\BootExcludeList::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The Kernel restarters — resolved and invoked from `bin/pest`
|
||||
* before any other Pest class is touched, so the list is exposed
|
||||
* on the Kernel rather than driven from `bin/pest` directly.
|
||||
*
|
||||
* @var array<int, class-string<Contracts\Restarter>>
|
||||
*/
|
||||
public const array RESTARTERS = [
|
||||
Restarters\XdebugRestarter::class,
|
||||
Restarters\PcovRestarter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new Kernel instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private Application $application,
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
//
|
||||
}
|
||||
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
|
||||
|
||||
/**
|
||||
* Boots the Kernel.
|
||||
@@ -112,9 +126,13 @@ final readonly class Kernel
|
||||
$configuration = Registry::get();
|
||||
$result = Facade::result();
|
||||
|
||||
return CallsAddsOutput::execute(
|
||||
$result = CallsAddsOutput::execute(
|
||||
Result::exitCode($configuration, $result),
|
||||
);
|
||||
|
||||
$this->terminate();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +140,12 @@ final readonly class Kernel
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
if ($this->terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->terminated = true;
|
||||
|
||||
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
|
||||
|
||||
assert($preBufferOutput instanceof KernelDump);
|
||||
@@ -139,7 +163,7 @@ final readonly class Kernel
|
||||
$this->terminate();
|
||||
|
||||
if (is_array($error = error_get_last())) {
|
||||
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
|
||||
if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Laravel\Pao\Execution;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -28,6 +29,10 @@ final class KernelDump
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
if (class_exists(Execution::class) && Execution::running()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ob_start(function (string $message): string {
|
||||
$this->buffer .= $message;
|
||||
|
||||
@@ -68,6 +73,10 @@ final class KernelDump
|
||||
|
||||
$type = 'INFO';
|
||||
|
||||
if (is_array($error = error_get_last()) && in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isInternalError($this->buffer)) {
|
||||
$type = 'ERROR';
|
||||
$this->buffer = str_replace(
|
||||
@@ -107,7 +116,6 @@ final class KernelDump
|
||||
*/
|
||||
private function isInternalError(string $output): bool
|
||||
{
|
||||
return str_contains($output, 'An error occurred inside PHPUnit.')
|
||||
|| str_contains($output, 'Fatal error');
|
||||
return str_contains($output, 'An error occurred inside PHPUnit.');
|
||||
}
|
||||
}
|
||||
|
||||
+23
-20
@@ -12,12 +12,9 @@ use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||
use PHPUnit\Event\TestSuite\TestSuite;
|
||||
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
||||
use PHPUnit\Framework\Exception as FrameworkException;
|
||||
@@ -252,23 +249,29 @@ final readonly class Converter
|
||||
...$result->testMarkedIncompleteEvents(),
|
||||
];
|
||||
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored
|
||||
|| $event instanceof AfterLastTestMethodErrored) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
$notPassedTests = [];
|
||||
|
||||
return $this->getTestCaseLocation($event->test());
|
||||
},
|
||||
$events
|
||||
)
|
||||
)
|
||||
);
|
||||
foreach ($events as $event) {
|
||||
if ($event instanceof AfterLastTestMethodErrored) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
continue;
|
||||
}
|
||||
if ($event instanceof AfterLastTestMethodFailed) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
continue;
|
||||
}
|
||||
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
|
||||
$notPassedTests[] = $event->testClassName();
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||
}
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun()
|
||||
- count(array_unique($notPassedTests))
|
||||
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||
|
||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if ($event->message() === '__TODO__') {
|
||||
return; // "todo" tests are reported in the summary, not as ignored tests...
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,12 @@ final class TeamCityLogger
|
||||
$telemetry->memoryUsageSinceStart(),
|
||||
$telemetry->durationSincePrevious(),
|
||||
$telemetry->memoryUsageSincePrevious(),
|
||||
$telemetry->userCpuTimeSinceStart(),
|
||||
$telemetry->systemCpuTimeSinceStart(),
|
||||
$telemetry->totalCpuTimeSinceStart(),
|
||||
$telemetry->userCpuTimeSincePrevious(),
|
||||
$telemetry->systemCpuTimeSincePrevious(),
|
||||
$telemetry->totalCpuTimeSincePrevious(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+134
-1
@@ -936,7 +936,7 @@ final class Expectation
|
||||
|
||||
if ($exception instanceof Closure) {
|
||||
$callback = $exception;
|
||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
||||
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||
|
||||
if (count($parameters) !== 1) {
|
||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||
@@ -954,6 +954,7 @@ final class Expectation
|
||||
} catch (Throwable $e) {
|
||||
|
||||
if ($exception instanceof Throwable) {
|
||||
// @phpstan-ignore-next-line
|
||||
expect($e)
|
||||
->toBeInstanceOf($exception::class, $message)
|
||||
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
||||
@@ -1141,6 +1142,22 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a ULID.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeUlid(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue(Str::isUlid($this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is between 2 specified values
|
||||
*
|
||||
@@ -1154,6 +1171,22 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is an email address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeEmail(string $message = ''): self
|
||||
{
|
||||
if ($message === '') {
|
||||
$message = "Failed asserting that {$this->value} is an email address.";
|
||||
}
|
||||
|
||||
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a url
|
||||
*
|
||||
@@ -1186,4 +1219,104 @@ final class Expectation
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is an IP address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeIpAddress(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a MAC address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeMacAddress(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a hostname.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeHostname(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a domain name.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeDomain(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
|
||||
&& str_contains($this->value, '.');
|
||||
|
||||
Assert::assertTrue($isValid, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a base64-encoded string.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeBase64(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
$decoded = base64_decode($this->value, true);
|
||||
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a hexadecimal string.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeHexadecimal(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue(ctype_xdigit($this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use PHPStan\Type\Type;
|
||||
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||
*
|
||||
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
|
||||
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
|
||||
|
||||
$varType = $scope->getType($expr->var);
|
||||
|
||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
||||
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ final class UsesCall
|
||||
$this->targets = [$filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
||||
*/
|
||||
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||
public function compact(): self
|
||||
{
|
||||
DefaultPrinter::compact(true);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '4.6.3';
|
||||
return '5.0.3';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
||||
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||
return $this->pushArgument('--cache-result', $arguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ trait HandleArguments
|
||||
*/
|
||||
public function popArgument(string $argument, array $arguments): array
|
||||
{
|
||||
$arguments = array_flip($arguments);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
|
||||
unset($arguments[$argument]);
|
||||
while ($key !== false) {
|
||||
unset($arguments[$key]);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
}
|
||||
|
||||
return array_values(array_flip($arguments));
|
||||
return array_values($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class Coverage implements AddsOutput, HandlesArguments
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
private const string COVERAGE_OPTION = 'coverage';
|
||||
|
||||
private const string MIN_OPTION = 'min';
|
||||
@@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
return false;
|
||||
}))];
|
||||
|
||||
$originals = array_flip($originals);
|
||||
foreach ($arguments as $argument) {
|
||||
unset($originals[$argument]);
|
||||
$originals = $this->popArgument($argument, $originals);
|
||||
}
|
||||
$originals = array_flip($originals);
|
||||
|
||||
$inputs = [];
|
||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||
|
||||
@@ -114,6 +114,41 @@ final readonly class Help implements HandlesArguments
|
||||
],
|
||||
];
|
||||
|
||||
$content['Tia'] = [
|
||||
[
|
||||
'arg' => '--tia',
|
||||
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
|
||||
],
|
||||
[
|
||||
'arg' => '--no-tia',
|
||||
'desc' => 'Disable test impact analysis for this run',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --fresh',
|
||||
'desc' => 'Discard the recorded dependency graph and record it again',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --filtered',
|
||||
'desc' => 'Narrow the run to the affected test files only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --locally',
|
||||
'desc' => 'Enable test impact analysis on local machines only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --baselined',
|
||||
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --refetch',
|
||||
'desc' => 'Force a fresh fetch of the shared dependency graph',
|
||||
],
|
||||
[
|
||||
'arg' => '--baseline',
|
||||
'desc' => 'Output to standard output the test impact analysis storage directory',
|
||||
],
|
||||
];
|
||||
|
||||
$content['Execution'] = [...[
|
||||
[
|
||||
'arg' => '--parallel',
|
||||
|
||||
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
|
||||
{
|
||||
$arguments = new ArgvInput;
|
||||
|
||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,10 +120,6 @@ final class ResultPrinter
|
||||
|
||||
$unexpectedOutput = $this->tail($outputFile);
|
||||
if ($unexpectedOutput !== '') {
|
||||
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->write($unexpectedOutput);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ namespace Pest\Plugins\Parallel\Paratest;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||
use ParaTest\Coverage\CoverageMerger;
|
||||
use ParaTest\JUnit\LogMerger;
|
||||
use ParaTest\JUnit\Writer;
|
||||
use ParaTest\Options;
|
||||
use ParaTest\RunnerInterface;
|
||||
use ParaTest\WrapperRunner\MissingResultsException;
|
||||
use ParaTest\WrapperRunner\SuiteLoader;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Result;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event\Facade as EventFacade;
|
||||
@@ -25,11 +26,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||
use PHPUnit\Util\ExcludeList;
|
||||
use ReflectionProperty;
|
||||
use SebastianBergmann\CodeCoverage\Node\Builder;
|
||||
use SebastianBergmann\CodeCoverage\Serialization\Merger;
|
||||
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
|
||||
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
|
||||
use SebastianBergmann\Timer\Timer;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function array_filter;
|
||||
use function array_merge;
|
||||
use function array_merge_recursive;
|
||||
use function array_shift;
|
||||
@@ -37,6 +44,8 @@ use function assert;
|
||||
use function count;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function filesize;
|
||||
use function is_file;
|
||||
use function max;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
@@ -49,14 +58,14 @@ use function usleep;
|
||||
*/
|
||||
final class WrapperRunner implements RunnerInterface
|
||||
{
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
/**
|
||||
* The merged test result from the parallel run.
|
||||
*/
|
||||
public static ?TestResult $result = null;
|
||||
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
private const int CYCLE_SLEEP = 10000;
|
||||
|
||||
/**
|
||||
@@ -83,6 +92,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
/** @var array<int,int> */
|
||||
private array $batches = [];
|
||||
|
||||
/** @var array<non-empty-string,true> */
|
||||
private array $requiredTestResultFiles = [];
|
||||
|
||||
/** @var array<non-empty-string,true> */
|
||||
private array $requiredCoverageFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $statusFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $progressFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $unexpectedOutputFiles = [];
|
||||
|
||||
@@ -135,6 +156,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
/** @var array<int, non-empty-string> $parameters */
|
||||
$parameters = $this->handleLaravelHerd($parameters);
|
||||
$parameters = $this->handleTia($parameters);
|
||||
|
||||
$parameters[] = $wrapper;
|
||||
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||
@@ -146,7 +168,6 @@ final class WrapperRunner implements RunnerInterface
|
||||
public function run(): int
|
||||
{
|
||||
$directory = dirname(__DIR__);
|
||||
assert($directory !== '');
|
||||
ExcludeList::addDirectory($directory);
|
||||
TestResultFacade::init();
|
||||
EventFacade::instance()->seal();
|
||||
@@ -183,6 +204,28 @@ final class WrapperRunner implements RunnerInterface
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widens pcov's instrumentation scope to the whole project for workers that
|
||||
* record TIA edges.
|
||||
*
|
||||
* pcov's default scope is a single source directory it auto-detects, so
|
||||
* `config/`, `routes/`, `bootstrap/` and every test's own file never reach
|
||||
* the recorder — a worker-recorded graph selects a fraction of what a
|
||||
* sequential one does. `pcov.directory` is only settable at startup, hence
|
||||
* the command line rather than an `ini_set()` inside the worker.
|
||||
*
|
||||
* @param array<int, non-empty-string> $parameters
|
||||
* @return array<int, non-empty-string>
|
||||
*/
|
||||
private function handleTia(array $parameters): array
|
||||
{
|
||||
if (! Tia::recordsEdgesInWorkers()) {
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
|
||||
}
|
||||
|
||||
private function startWorkers(): void
|
||||
{
|
||||
for ($token = 1; $token <= $this->options->processes; $token++) {
|
||||
@@ -213,7 +256,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
if (
|
||||
$this->exitcode > 0
|
||||
&& $this->options->configuration->stopOnFailure()
|
||||
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
||||
) {
|
||||
$this->pending = [];
|
||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||
@@ -228,6 +271,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
private function flushWorker(WrapperWorker $worker): void
|
||||
{
|
||||
if ($worker->hasExecutedTests()) {
|
||||
$testResultFile = $worker->testResultFile->getPathname();
|
||||
|
||||
if ($testResultFile !== '') {
|
||||
$this->requiredTestResultFiles[$testResultFile] = true;
|
||||
}
|
||||
|
||||
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
|
||||
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->exitcode = max($this->exitcode, $worker->getExitCode());
|
||||
$this->printer->printFeedback(
|
||||
$worker->progressFile,
|
||||
@@ -275,10 +330,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$worker->start();
|
||||
$this->batches[$token] = 0;
|
||||
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->statusFiles[] = $worker->statusFile;
|
||||
$this->progressFiles[] = $worker->progressFile;
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->testResultFiles[] = $worker->testResultFile;
|
||||
|
||||
if (isset($worker->resultCacheFile)) {
|
||||
$this->resultCacheFiles[] = $worker->resultCacheFile;
|
||||
}
|
||||
|
||||
if (isset($worker->junitFile)) {
|
||||
$this->junitFiles[] = $worker->junitFile;
|
||||
}
|
||||
@@ -311,6 +371,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
private function complete(TestResult $testResultSum): int
|
||||
{
|
||||
$missingTestResultFiles = [];
|
||||
|
||||
foreach ($this->requiredTestResultFiles as $filePath => $true) {
|
||||
if (is_file($filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingTestResultFiles[] = $filePath;
|
||||
}
|
||||
|
||||
if ($missingTestResultFiles !== []) {
|
||||
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
|
||||
}
|
||||
|
||||
foreach ($this->testResultFiles as $testResultFile) {
|
||||
if (! $testResultFile->isFile()) {
|
||||
continue;
|
||||
@@ -345,6 +419,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueDeprecationEvents(), $testResult->testRunnerTriggeredIssueDeprecationEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueErrorEvents(), $testResult->testRunnerTriggeredIssueErrorEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueNoticeEvents(), $testResult->testRunnerTriggeredIssueNoticeEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(), $testResult->testRunnerTriggeredIssuePhpDeprecationEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(), $testResult->testRunnerTriggeredIssuePhpNoticeEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpWarningEvents(), $testResult->testRunnerTriggeredIssuePhpWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredIssueWarningEvents(), $testResult->testRunnerTriggeredIssueWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||
@@ -380,8 +468,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||
array_values(array_filter(
|
||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
|
||||
)),
|
||||
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
|
||||
$testResultSum->errors(),
|
||||
$testResultSum->deprecations(),
|
||||
$testResultSum->notices(),
|
||||
@@ -417,8 +512,11 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||
|
||||
$this->clearFiles($this->statusFiles);
|
||||
$this->clearFiles($this->progressFiles);
|
||||
$this->clearFiles($this->unexpectedOutputFiles);
|
||||
$this->clearFiles($this->testResultFiles);
|
||||
$this->clearFiles($this->resultCacheFiles);
|
||||
$this->clearFiles($this->coverageFiles);
|
||||
$this->clearFiles($this->junitFiles);
|
||||
$this->clearFiles($this->teamcityFiles);
|
||||
@@ -433,6 +531,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
$missingCoverageFiles = [];
|
||||
|
||||
foreach ($this->requiredCoverageFiles as $filePath => $true) {
|
||||
if (is_file($filePath) && filesize($filePath) !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingCoverageFiles[] = $filePath;
|
||||
}
|
||||
|
||||
if ($missingCoverageFiles !== []) {
|
||||
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
|
||||
}
|
||||
|
||||
$coverageManager = new CodeCoverage;
|
||||
$coverageManager->init(
|
||||
$this->options->configuration,
|
||||
@@ -448,10 +560,33 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
return;
|
||||
}
|
||||
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
||||
foreach ($this->coverageFiles as $coverageFile) {
|
||||
$coverageMerger->addCoverageFromFile($coverageFile);
|
||||
$coverageFiles = [];
|
||||
foreach ($this->coverageFiles as $fileInfo) {
|
||||
$realPath = $fileInfo->getRealPath();
|
||||
if ($realPath !== false && $realPath !== '') {
|
||||
$coverageFiles[] = $realPath;
|
||||
}
|
||||
}
|
||||
$serializedCoverage = (new Merger)->merge($coverageFiles);
|
||||
|
||||
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
|
||||
$serializedCoverage['codeCoverage'],
|
||||
$serializedCoverage['testResults'],
|
||||
$serializedCoverage['basePath'],
|
||||
);
|
||||
$codeCoverage = $coverageManager->codeCoverage();
|
||||
$codeCoverage->excludeUncoveredFiles();
|
||||
|
||||
$mergedData = $serializedCoverage['codeCoverage'];
|
||||
$basePath = $serializedCoverage['basePath'];
|
||||
if ($basePath !== '') {
|
||||
foreach ($mergedData->coveredFiles() as $relativePath) {
|
||||
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||
}
|
||||
}
|
||||
$codeCoverage->setData($mergedData);
|
||||
$codeCoverage->setTests($serializedCoverage['testResults']);
|
||||
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
|
||||
|
||||
$coverageManager->generateReports(
|
||||
$this->printer->printer,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||
use ParaTest\Options;
|
||||
use PHPUnit\Event\Telemetry\CpuTime;
|
||||
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\Telemetry\Info;
|
||||
@@ -147,11 +148,20 @@ final class CompactPrinter
|
||||
MemoryUsage::fromBytes(0),
|
||||
MemoryUsage::fromBytes(0),
|
||||
$garbageCollectorStatus,
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
),
|
||||
$telemetryDuration,
|
||||
MemoryUsage::fromBytes(0),
|
||||
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
|
||||
MemoryUsage::fromBytes(0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
);
|
||||
|
||||
$this->style->writeRecap($state, $telemetry, $testResult);
|
||||
|
||||
+105
-12
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string SHARD_OPTION = 'shard';
|
||||
|
||||
/**
|
||||
* The maximum length allowed for the filter argument.
|
||||
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
|
||||
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
|
||||
*/
|
||||
private const int MAX_FILTER_LENGTH = 32768;
|
||||
|
||||
/**
|
||||
* The shard index and total number of shards.
|
||||
*
|
||||
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
self::$timeBalanced = true;
|
||||
self::$shardsOutdated = $newTests !== [];
|
||||
} else {
|
||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||
}
|
||||
|
||||
self::$shard = [
|
||||
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||
$filter = $this->buildFilterArgument($testsToRun);
|
||||
|
||||
$this->ensureFilterLengthIsSafe($filter);
|
||||
|
||||
return [...$arguments, '--filter', $filter];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function allTests(array $arguments): array
|
||||
{
|
||||
$output = (new Process([
|
||||
'php',
|
||||
...$this->removeParallelArguments($arguments),
|
||||
'--list-tests',
|
||||
]))->setTimeout(120)->mustRun()->getOutput();
|
||||
$command = $this->buildListTestsCommand(
|
||||
$arguments,
|
||||
TestSuite::getInstance()->testPath,
|
||||
);
|
||||
|
||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
||||
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
return $this->parseListTestsOutput($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function removeParallelArguments(array $arguments): array
|
||||
{
|
||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
||||
return array_values(array_filter(
|
||||
$arguments,
|
||||
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
|
||||
&& ! str_starts_with($argument, '--processes'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the subprocess command used to enumerate tests via `--list-tests`.
|
||||
*
|
||||
* @param list<string> $arguments
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildListTestsCommand(array $arguments, string $testPath): array
|
||||
{
|
||||
$filtered = $this->removeParallelArguments($arguments);
|
||||
|
||||
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `--list-tests` output into a unique list of test class FQCNs.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseListTestsOutput(string $output): array
|
||||
{
|
||||
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the filter argument for the given tests to run.
|
||||
*
|
||||
* @param array<int, string> $testsToRun
|
||||
*/
|
||||
private function buildFilterArgument(mixed $testsToRun): string
|
||||
private function buildFilterArgument(array $testsToRun): string
|
||||
{
|
||||
return addslashes(implode('|', $testsToRun));
|
||||
if ($testsToRun === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $tree */
|
||||
$tree = [];
|
||||
foreach ($testsToRun as $class) {
|
||||
$parts = explode('\\', $class);
|
||||
$current = &$tree;
|
||||
foreach ($parts as $part) {
|
||||
if (! isset($current[$part])) {
|
||||
$current[$part] = [];
|
||||
}
|
||||
$current = &$current[$part];
|
||||
}
|
||||
}
|
||||
|
||||
$buildRegex = function (array $tree) use (&$buildRegex): string {
|
||||
$parts = [];
|
||||
foreach ($tree as $key => $sub) {
|
||||
$subRegex = $buildRegex($sub);
|
||||
if ($subRegex === '') {
|
||||
$parts[] = preg_quote($key, '/');
|
||||
} else {
|
||||
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
|
||||
}
|
||||
}
|
||||
|
||||
return implode('|', $parts);
|
||||
};
|
||||
|
||||
return $buildRegex($tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the filter length is safe for the current environment.
|
||||
*
|
||||
* @throws InvalidOption
|
||||
*/
|
||||
private function ensureFilterLengthIsSafe(string $filter): void
|
||||
{
|
||||
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
|
||||
|
||||
if (strlen($filter) > $maxLength) {
|
||||
throw new InvalidOption(sprintf(
|
||||
'The generated filter for this shard is too long (%d characters). '.
|
||||
'This can cause issues with some environments (limit is %d characters). '.
|
||||
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
|
||||
strlen($filter),
|
||||
$maxLength
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2404
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,621 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Exceptions\BaselineFetchFailed;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class BaselineSync
|
||||
{
|
||||
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
||||
|
||||
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
||||
|
||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||
|
||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||
|
||||
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
|
||||
|
||||
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
|
||||
|
||||
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||
|
||||
private const array DIAGNOSES = [
|
||||
'network' => [
|
||||
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
|
||||
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
||||
],
|
||||
'gh-auth' => [
|
||||
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
|
||||
'message' => 'authentication failed — run `gh auth login` and retry.',
|
||||
],
|
||||
'rate-limit' => [
|
||||
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
|
||||
'message' => 'GitHub API rate limit hit — try again later.',
|
||||
],
|
||||
'not-found' => [
|
||||
'pattern' => '/404|not found|repository not found/i',
|
||||
'message' => 'workflow or artifact not found in repo.',
|
||||
],
|
||||
'forbidden' => [
|
||||
'pattern' => '/403|forbidden|access denied/i',
|
||||
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private State $state,
|
||||
private OutputInterface $output,
|
||||
) {}
|
||||
|
||||
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);
|
||||
|
||||
if ($repo === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
|
||||
$this->formatDuration($remaining),
|
||||
));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
||||
$payload = $result['payload'];
|
||||
$failureKind = $result['failureKind'];
|
||||
|
||||
if ($payload === null) {
|
||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($payload['coverage'] !== null) {
|
||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||
}
|
||||
|
||||
$this->clearCooldown();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function cooldownRemaining(): ?int
|
||||
{
|
||||
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
|
||||
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$remaining = $decoded['until'] - time();
|
||||
|
||||
return $remaining > 0 ? $remaining : null;
|
||||
}
|
||||
|
||||
private function startCooldown(): void
|
||||
{
|
||||
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
|
||||
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
|
||||
]));
|
||||
}
|
||||
|
||||
private function clearCooldown(): void
|
||||
{
|
||||
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
|
||||
}
|
||||
|
||||
private function formatDuration(int $seconds): string
|
||||
{
|
||||
if ($seconds >= 3600) {
|
||||
return (int) round($seconds / 3600).'h';
|
||||
}
|
||||
|
||||
if ($seconds >= 60) {
|
||||
return (int) round($seconds / 60).'m';
|
||||
}
|
||||
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
private function emitPublishInstructions(): void
|
||||
{
|
||||
if ($this->isCi()) {
|
||||
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
||||
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
|
||||
}
|
||||
|
||||
private function isCi(): bool
|
||||
{
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
|| getenv('GITLAB_CI') === 'true'
|
||||
|| getenv('CIRCLECI') === 'true';
|
||||
}
|
||||
|
||||
private function detectGitHubRepo(string $projectRoot): ?string
|
||||
{
|
||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
|
||||
if (! is_file($gitConfig)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($gitConfig);
|
||||
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $match[1];
|
||||
|
||||
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
|
||||
*/
|
||||
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
||||
{
|
||||
$this->validateGhDependencies($hasAnchor);
|
||||
|
||||
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
||||
|
||||
if ($listError !== null) {
|
||||
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
));
|
||||
|
||||
return ['payload' => null, 'failureKind' => $listError['kind']];
|
||||
}
|
||||
|
||||
if ($runId === null) {
|
||||
return ['payload' => null, 'failureKind' => 'no-runs'];
|
||||
}
|
||||
|
||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||
|
||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
||||
@touch($runCacheDir);
|
||||
|
||||
$this->renderChild(sprintf(
|
||||
'Using cached baseline from %s (run %s).',
|
||||
$repo,
|
||||
$runId,
|
||||
));
|
||||
|
||||
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
|
||||
}
|
||||
|
||||
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
||||
return ['payload' => null, 'failureKind' => null];
|
||||
}
|
||||
|
||||
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
|
||||
|
||||
if (! $download['success']) {
|
||||
return ['payload' => null, 'failureKind' => $download['failureKind']];
|
||||
}
|
||||
|
||||
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
|
||||
|
||||
$this->trimDownloadCache($projectRoot);
|
||||
|
||||
return ['payload' => $payload, 'failureKind' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{kind: string, message: string} $diagnosis
|
||||
*/
|
||||
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
|
||||
{
|
||||
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
|
||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
private function validateGhDependencies(bool $hasAnchor): void
|
||||
{
|
||||
if (! $this->commandExists('gh')) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||
'Install it from https://cli.github.com.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
if (! $this->ghAuthenticated()) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
||||
'Run `gh auth login` and retry.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, failureKind: ?string}
|
||||
*/
|
||||
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
|
||||
{
|
||||
$artifactSize = $this->artifactSize($repo, $runId);
|
||||
|
||||
$this->output->writeln('');
|
||||
$this->renderChild($artifactSize !== null
|
||||
? sprintf(
|
||||
'Downloading TIA baseline (%s) from %s…',
|
||||
$this->formatSize($artifactSize),
|
||||
$repo,
|
||||
)
|
||||
: sprintf(
|
||||
'Downloading TIA baseline from %s…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
$process = new Process([
|
||||
'gh', 'run', 'download', $runId,
|
||||
'-R', $repo,
|
||||
'-n', self::ARTIFACT_NAME,
|
||||
'-D', $runCacheDir,
|
||||
]);
|
||||
$process->setTimeout(900.0);
|
||||
$process->start();
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$tick = 0;
|
||||
|
||||
while ($process->isRunning()) {
|
||||
$this->renderDownloadProgress($startedAt, $tick++);
|
||||
usleep(120_000);
|
||||
}
|
||||
|
||||
$process->wait();
|
||||
$this->clearProgressLine();
|
||||
|
||||
if ($process->isSuccessful()) {
|
||||
return ['success' => true, 'failureKind' => null];
|
||||
}
|
||||
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||
|
||||
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
|
||||
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Baseline download failed — %s',
|
||||
$diagnosis['message'],
|
||||
));
|
||||
|
||||
return ['success' => false, 'failureKind' => $diagnosis['kind']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
|
||||
*/
|
||||
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
|
||||
{
|
||||
$payload = $this->readArtifact($runCacheDir);
|
||||
|
||||
if ($payload === null) {
|
||||
$this->cleanup($runCacheDir);
|
||||
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
||||
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function artifactSize(string $repo, string $runId): ?int
|
||||
{
|
||||
$process = new Process([
|
||||
'gh', 'api',
|
||||
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
||||
'--jq', sprintf(
|
||||
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
|
||||
self::ARTIFACT_NAME,
|
||||
),
|
||||
]);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
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, sizeOnDisk: int}|null
|
||||
*/
|
||||
private function readArtifact(string $dir): ?array
|
||||
{
|
||||
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||
|
||||
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
|
||||
|
||||
if ($graph === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
|
||||
|
||||
return [
|
||||
'graph' => $graph,
|
||||
'coverage' => $coverage === false ? null : $coverage,
|
||||
'sizeOnDisk' => $this->dirSize($dir),
|
||||
];
|
||||
}
|
||||
|
||||
private function downloadCacheDir(string $projectRoot): string
|
||||
{
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
|
||||
}
|
||||
|
||||
private function safeRunId(string $runId): string
|
||||
{
|
||||
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
||||
|
||||
return $sanitised === '' ? 'unknown' : $sanitised;
|
||||
}
|
||||
|
||||
private function trimDownloadCache(string $projectRoot): void
|
||||
{
|
||||
$root = $this->downloadCacheDir($projectRoot);
|
||||
|
||||
if (! is_dir($root)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = @scandir($root);
|
||||
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (in_array($entry, ['.', '..'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mtime = @filemtime($path);
|
||||
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
|
||||
}
|
||||
|
||||
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
usort(
|
||||
$candidates,
|
||||
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
|
||||
);
|
||||
|
||||
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
|
||||
$this->cleanup($stale['path']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
||||
*/
|
||||
private function latestSuccessfulRunIdWithError(string $repo): array
|
||||
{
|
||||
$process = new Process([
|
||||
'gh', 'run', 'list',
|
||||
'-R', $repo,
|
||||
'--workflow', self::WORKFLOW_FILE,
|
||||
'--status', 'success',
|
||||
'--limit', '1',
|
||||
'--json', 'databaseId',
|
||||
'--jq', '.[0].databaseId // empty',
|
||||
]);
|
||||
$process->setTimeout(30.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
|
||||
}
|
||||
|
||||
$runId = trim($process->getOutput());
|
||||
|
||||
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
|
||||
{
|
||||
$process = new Process(['which', $cmd]);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful();
|
||||
}
|
||||
|
||||
private function cleanup(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
|
||||
/** @var \SplFileInfo $entry */
|
||||
foreach ($iterator as $entry) {
|
||||
if ($entry->isDir()) {
|
||||
@rmdir($entry->getPathname());
|
||||
} else {
|
||||
@unlink($entry->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
private function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1024 * 1024) {
|
||||
return sprintf('%.1f MB', $bytes / 1024 / 1024);
|
||||
}
|
||||
|
||||
if ($bytes >= 1024) {
|
||||
return sprintf('%.1f KB', $bytes / 1024);
|
||||
}
|
||||
|
||||
return $bytes.' B';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Bootstrapper implements BootstrapperContract
|
||||
{
|
||||
public function __construct(private Container $container) {}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$testSuite = $this->container->get(TestSuite::class);
|
||||
assert($testSuite instanceof TestSuite);
|
||||
|
||||
$tempDir = Storage::tempDir($testSuite->rootPath);
|
||||
|
||||
$this->container->add(State::class, new FileState($tempDir));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class ChangedFiles
|
||||
{
|
||||
public function __construct(private string $projectRoot) {}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $files project-relative paths.
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
||||
{
|
||||
if ($lastRunTree === []) {
|
||||
return $files;
|
||||
}
|
||||
|
||||
$candidates = array_fill_keys($files, true);
|
||||
|
||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
||||
$candidates[$snapshotted] = true;
|
||||
}
|
||||
|
||||
$remaining = [];
|
||||
|
||||
foreach (array_keys($candidates) as $file) {
|
||||
$snapshot = $lastRunTree[$file] ?? null;
|
||||
$current = $this->currentHash($file);
|
||||
|
||||
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
private function currentHash(string $relativePath): ?string
|
||||
{
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $files
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
*/
|
||||
public function snapshotTree(array $files): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
$out[$file] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($absolute);
|
||||
|
||||
if ($hash !== false) {
|
||||
$out[$file] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
*/
|
||||
public function since(?string $sha): ?array
|
||||
{
|
||||
$files = [];
|
||||
|
||||
if ($sha !== null && $sha !== '') {
|
||||
if (! $this->shaIsReachable($sha)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = array_merge($files, $this->diffSinceSha($sha));
|
||||
}
|
||||
|
||||
$files = array_merge($files, $this->workingTreeChanges());
|
||||
|
||||
$unique = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
$unique[$file] = true;
|
||||
}
|
||||
|
||||
$candidates = array_keys($this->filterIgnored($unique));
|
||||
|
||||
if ($sha !== null && $sha !== '') {
|
||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||
}
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $files
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function filterBehaviourallyUnchanged(array $files, string $sha): array
|
||||
{
|
||||
$remaining = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$currentHash = $this->currentHash($file);
|
||||
|
||||
if ($currentHash === null) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineContent = $this->contentAtSha($sha, $file);
|
||||
|
||||
if ($baselineContent === null) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
private function contentAtSha(string $sha, string $path): ?string
|
||||
{
|
||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
||||
$process->setTimeout(5.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $process->getOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $candidates
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function filterIgnored(array $candidates): array
|
||||
{
|
||||
if ($candidates === []) {
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
$process = new Process(
|
||||
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->setTimeout(5.0);
|
||||
$process->setInput(implode("\x00", array_keys($candidates)));
|
||||
$process->run();
|
||||
|
||||
$exitCode = $process->getExitCode();
|
||||
|
||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
|
||||
if ($output === '') {
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
|
||||
if ($ignored !== '') {
|
||||
unset($candidates[$ignored]);
|
||||
}
|
||||
}
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
public function currentBranch(): ?string
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$branch = trim($process->getOutput());
|
||||
|
||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repository's default branch — the one every other branch's baseline
|
||||
* falls back to reading.
|
||||
*
|
||||
* Advisory, unlike {@see self::currentBranch()}: a repository that cannot
|
||||
* answer the question is not a broken repository. A remote-less checkout
|
||||
* has no `origin/HEAD`, and plenty of CI checkouts never run
|
||||
* `git remote set-head`, so every step here fails soft and the caller is
|
||||
* left to pick its own default.
|
||||
*/
|
||||
public function defaultBranch(): ?string
|
||||
{
|
||||
$head = $this->gitOutput(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
||||
|
||||
if ($head !== null) {
|
||||
$branch = preg_replace('#^origin/#', '', $head);
|
||||
|
||||
if (is_string($branch) && $branch !== '') {
|
||||
return $branch;
|
||||
}
|
||||
}
|
||||
|
||||
// `init.defaultBranch` is a setting of the machine, not of the
|
||||
// repository — it names what `git init` would have called the first
|
||||
// branch here, which is worth nothing once the repository disagrees.
|
||||
// Taken only when a branch by that name actually exists.
|
||||
$configured = $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']);
|
||||
|
||||
if ($configured === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$exists = $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/heads/'.$configured]) !== null
|
||||
|| $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/'.$configured]) !== null;
|
||||
|
||||
return $exists ? $configured : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the repository has any remote configured.
|
||||
*
|
||||
* Advisory like {@see self::defaultBranch()} — a `git` that cannot answer
|
||||
* is reported as "no remote", and the caller decides what that means.
|
||||
*/
|
||||
public function hasRemote(): bool
|
||||
{
|
||||
return $this->gitOutput(['git', 'remote']) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $command
|
||||
*/
|
||||
private function gitOutput(array $command): ?string
|
||||
{
|
||||
$process = new Process($command, $this->projectRoot);
|
||||
$process->setTimeout(5.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
|
||||
return $output === '' ? null : $output;
|
||||
}
|
||||
|
||||
private function shaIsReachable(string $sha): bool
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
|
||||
return $process->getExitCode() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function diffSinceSha(string $sha): array
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'diff', '--name-only', $sha.'..HEAD'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
return $this->splitLines($process->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function workingTreeChanges(): array
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
|
||||
if ($output === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$records = explode("\x00", rtrim($output, "\x00"));
|
||||
$files = [];
|
||||
$count = count($records);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$record = $records[$i];
|
||||
|
||||
if (strlen($record) < 4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = substr($record, 0, 2);
|
||||
$path = substr($record, 3);
|
||||
|
||||
if ($status[0] === 'R' || $status[0] === 'C') {
|
||||
$files[] = $path;
|
||||
|
||||
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
|
||||
$files[] = $records[$i + 1];
|
||||
$i++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = $path;
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
public function currentSha(): ?string
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$sha = trim($process->getOutput());
|
||||
|
||||
return $sha === '' ? null : $sha;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function splitLines(string $output): array
|
||||
{
|
||||
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
return $lines === false ? [] : $lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* The default branch as the CI provider itself reports it.
|
||||
*
|
||||
* Worth asking before git: a CI checkout is the one place where git knows the
|
||||
* least. `actions/checkout` builds the working copy with `git init` plus a
|
||||
* single-ref `fetch` rather than a `clone`, so `origin/HEAD` is never set and
|
||||
* `init.defaultBranch` — a setting of the runner image, not of the repository —
|
||||
* is all git has left to offer. The provider, meanwhile, states the answer
|
||||
* outright in the environment it handed us.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CiDefaultBranch
|
||||
{
|
||||
/**
|
||||
* Advisory, like every other source in the chain: anything unreadable,
|
||||
* unparsable, or simply absent means "no answer", never a failure.
|
||||
*/
|
||||
public static function detect(): ?string
|
||||
{
|
||||
return self::fromGitLab() ?? self::fromGitHubEvent();
|
||||
}
|
||||
|
||||
private static function fromGitLab(): ?string
|
||||
{
|
||||
return self::environment('CI_DEFAULT_BRANCH');
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub publishes no default-branch variable, but every repository-scoped
|
||||
* event payload carries `repository.default_branch`, and the path to that
|
||||
* payload is in the environment.
|
||||
*/
|
||||
private static function fromGitHubEvent(): ?string
|
||||
{
|
||||
$path = self::environment('GITHUB_EVENT_PATH');
|
||||
|
||||
if ($path === null || ! is_file($path) || ! is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($contents, true);
|
||||
|
||||
if (! is_array($payload) || ! is_array($payload['repository'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$branch = $payload['repository']['default_branch'] ?? null;
|
||||
|
||||
return is_string($branch) && $branch !== '' ? $branch : null;
|
||||
}
|
||||
|
||||
private static function environment(string $name): ?string
|
||||
{
|
||||
$value = getenv($name);
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function always(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markEnabled();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function locally(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markEnabled();
|
||||
$watchPatterns->markLocally();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function filtered(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markFiltered();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function baselined(): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->markBaselined();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch whose baseline every other branch falls back to reading.
|
||||
*
|
||||
* Autodetected from the repository when left unset; declare it here when
|
||||
* the repository cannot answer for itself — no `origin/HEAD`, or an
|
||||
* `init.defaultBranch` that disagrees with reality.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function defaultBranch(string $branch): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->setDefaultBranch($branch);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $patterns glob → project-relative test dir
|
||||
* @return $this
|
||||
*/
|
||||
public function watch(array $patterns): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->add($patterns);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ContentHash
|
||||
{
|
||||
public static function of(string $absolute): string|false
|
||||
{
|
||||
$raw = @file_get_contents($absolute);
|
||||
|
||||
if ($raw === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::ofContent($absolute, $raw);
|
||||
}
|
||||
|
||||
public static function ofContent(string $path, string $raw): string
|
||||
{
|
||||
$lower = strtolower($path);
|
||||
|
||||
if (str_ends_with($lower, '.blade.php')) {
|
||||
return self::hashBladeContent($raw);
|
||||
}
|
||||
|
||||
if (str_ends_with($lower, '.php')) {
|
||||
return self::hashPhpContent($raw);
|
||||
}
|
||||
|
||||
foreach (['.vue', '.tsx', '.jsx', '.svelte', '.ts', '.js', '.mjs', '.cjs', '.mts'] as $extension) {
|
||||
if (str_ends_with($lower, $extension)) {
|
||||
return self::hashJsContent($raw);
|
||||
}
|
||||
}
|
||||
|
||||
return hash('xxh128', $raw);
|
||||
}
|
||||
|
||||
private static function hashPhpContent(string $raw): string
|
||||
{
|
||||
$tokens = @token_get_all($raw);
|
||||
|
||||
if ($tokens === []) {
|
||||
return hash('xxh128', $raw);
|
||||
}
|
||||
|
||||
$normalised = '';
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (is_array($token)) {
|
||||
if ($token[0] === T_WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
if ($token[0] === T_COMMENT) {
|
||||
continue;
|
||||
}
|
||||
if ($token[0] === T_DOC_COMMENT) {
|
||||
continue;
|
||||
}
|
||||
$normalised .= $token[1];
|
||||
} else {
|
||||
$normalised .= $token;
|
||||
}
|
||||
}
|
||||
|
||||
return hash('xxh128', $normalised);
|
||||
}
|
||||
|
||||
private static function hashBladeContent(string $raw): string
|
||||
{
|
||||
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
||||
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
||||
|
||||
return hash('xxh128', trim($stripped));
|
||||
}
|
||||
|
||||
private static function hashJsContent(string $raw): string
|
||||
{
|
||||
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
|
||||
$stripped = preg_replace('/^\s*\/\*.*?\*\/\s*$/sm', '', $stripped) ?? $stripped;
|
||||
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
||||
|
||||
return hash('xxh128', trim($stripped));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Lockfile
|
||||
{
|
||||
public function applies(string $filename): bool;
|
||||
|
||||
public function fingerprint(string $contents): ?string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface State
|
||||
{
|
||||
public function read(string $key): ?string;
|
||||
|
||||
public function write(string $key, string $content): bool;
|
||||
|
||||
public function delete(string $key): bool;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keysWithPrefix(string $prefix): array;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface WatchDefault
|
||||
{
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
|
||||
*/
|
||||
public function defaults(string $projectRoot, string $testPath): array;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoverageCollector
|
||||
{
|
||||
/**
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
private array $classFileCache = [];
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function perTestFiles(): array
|
||||
{
|
||||
if (! PhpUnitCodeCoverage::instance()->isActive()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$lineCoverage = PhpUnitCodeCoverage::instance()
|
||||
->codeCoverage()
|
||||
->getData()
|
||||
->lineCoverage();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var array<string, array<string, true>> $edges */
|
||||
$edges = [];
|
||||
|
||||
foreach ($lineCoverage as $sourceFile => $lines) {
|
||||
$testIds = [];
|
||||
|
||||
foreach ($lines as $hits) {
|
||||
if ($hits === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($hits as $id) {
|
||||
$testIds[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($testIds) as $testId) {
|
||||
$testFile = $this->testIdToFile($testId);
|
||||
|
||||
if ($testFile === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$edges[$testFile][$sourceFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($edges as $testFile => $sources) {
|
||||
$out[$testFile] = array_keys($sources);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->classFileCache = [];
|
||||
}
|
||||
|
||||
private function testIdToFile(string $testId): ?string
|
||||
{
|
||||
$hash = strpos($testId, '#');
|
||||
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
|
||||
|
||||
if (! str_contains($identifier, '::')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$className] = explode('::', $identifier, 2);
|
||||
|
||||
if (array_key_exists($className, $this->classFileCache)) {
|
||||
return $this->classFileCache[$className];
|
||||
}
|
||||
|
||||
$file = $this->resolveClassFile($className);
|
||||
$this->classFileCache[$className] = $file;
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
private function resolveClassFile(string $className): ?string
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||
|
||||
return $className::$__filename;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||
use SebastianBergmann\CodeCoverage\Filter;
|
||||
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoverageMerger
|
||||
{
|
||||
public static function applyIfMarked(string $reportPath): void
|
||||
{
|
||||
$state = self::state();
|
||||
|
||||
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$state->delete(Tia::KEY_COVERAGE_MARKER);
|
||||
|
||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if ($cachedBytes === null) {
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current instanceof CodeCoverage) {
|
||||
self::primeUncoveredFiles($current);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$decoded = self::decompress($cachedBytes);
|
||||
|
||||
if ($decoded === null) {
|
||||
$state->delete(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cached = self::unserializeCoverage($decoded);
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::primeUncoveredFiles($cached);
|
||||
self::primeUncoveredFiles($current);
|
||||
|
||||
self::stripCurrentTestsFromCached($cached, $current);
|
||||
|
||||
$cached->merge($current);
|
||||
|
||||
$serialised = serialize($cached);
|
||||
|
||||
@file_put_contents(
|
||||
$reportPath,
|
||||
'<?php return unserialize('.var_export($serialised, true).");\n",
|
||||
);
|
||||
$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;
|
||||
}
|
||||
|
||||
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
|
||||
{
|
||||
$currentIds = self::collectTestIds($current);
|
||||
|
||||
if ($currentIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cachedData = $cached->getData();
|
||||
$lineCoverage = $cachedData->lineCoverage();
|
||||
|
||||
foreach ($lineCoverage as $file => $lines) {
|
||||
foreach ($lines as $line => $ids) {
|
||||
if ($ids === null) {
|
||||
continue;
|
||||
}
|
||||
if ($ids === []) {
|
||||
continue;
|
||||
}
|
||||
$filtered = array_values(array_diff($ids, $currentIds));
|
||||
|
||||
if ($filtered !== $ids) {
|
||||
$lineCoverage[$file][$line] = $filtered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cachedData->setLineCoverage($lineCoverage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function collectTestIds(CodeCoverage $coverage): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($coverage->getData()->lineCoverage() as $lines) {
|
||||
foreach ($lines as $hits) {
|
||||
if ($hits === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($hits as $id) {
|
||||
$ids[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($ids);
|
||||
}
|
||||
|
||||
private static function state(): State
|
||||
{
|
||||
$state = Container::getInstance()->get(State::class);
|
||||
assert($state instanceof State);
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
||||
{
|
||||
if (! is_file($reportPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var mixed $value */
|
||||
$value = require $reportPath;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
|
||||
if ($value instanceof CodeCoverage) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
|
||||
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
|
||||
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
|
||||
// be rebuilt into one before it can be merged.
|
||||
return self::coverageFromSerializedData($reportPath);
|
||||
}
|
||||
|
||||
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
|
||||
{
|
||||
if ($reportPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$serialized = new Unserializer()->unserialize($reportPath);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $serialized['codeCoverage'];
|
||||
$basePath = $serialized['basePath'];
|
||||
|
||||
if ($basePath !== '') {
|
||||
foreach ($data->coveredFiles() as $relativePath) {
|
||||
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
$coverage = self::emptyCoverage();
|
||||
|
||||
if (! $coverage instanceof CodeCoverage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage->setData($data);
|
||||
$coverage->setTests($serialized['testResults']);
|
||||
|
||||
return $coverage;
|
||||
}
|
||||
|
||||
private static function emptyCoverage(): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
$filter = new Filter;
|
||||
|
||||
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
$value = @unserialize($bytes);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BladeEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
private const string MARKER = 'pest.tia.blade-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
{
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$containerClass = self::CONTAINER_CLASS;
|
||||
|
||||
if (! class_exists($containerClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var object $app */
|
||||
$app = $containerClass::getInstance();
|
||||
|
||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$app->instance(self::MARKER, true);
|
||||
|
||||
$factory = $app->make('view');
|
||||
|
||||
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$factory->composer('*', static function (object $view) use ($recorder): void {
|
||||
if (! method_exists($view, 'getPath')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var mixed $path */
|
||||
$path = $view->getPath();
|
||||
|
||||
if (is_string($path) && $path !== '') {
|
||||
$recorder->linkSource($path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Edges;
|
||||
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InertiaEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||
|
||||
private const string MARKER = 'pest.tia.inertia-edges-armed';
|
||||
|
||||
public static function arm(Recorder $recorder): void
|
||||
{
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$containerClass = self::CONTAINER_CLASS;
|
||||
|
||||
if (! class_exists($containerClass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var object $app */
|
||||
$app = $containerClass::getInstance();
|
||||
|
||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$app->instance(self::MARKER, true);
|
||||
|
||||
/** @var object $events */
|
||||
$events = $app->make('events');
|
||||
|
||||
if (! method_exists($events, 'listen')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
||||
if (! property_exists($event, 'response') || ! is_object($event->response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$component = self::extractComponent($event->response);
|
||||
|
||||
if ($component !== null) {
|
||||
$recorder->linkInertiaComponent($component);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static function extractComponent(object $response): ?string
|
||||
{
|
||||
$content = self::readContent($response);
|
||||
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::isInertiaJsonResponse($response)) {
|
||||
return self::componentFromJson($content);
|
||||
}
|
||||
|
||||
if (str_contains($content, 'type="application/json"')
|
||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_contains($content, 'data-page=')
|
||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||
return self::componentFromJson(html_entity_decode($match[1]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function isInertiaJsonResponse(object $response): bool
|
||||
{
|
||||
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$headers = $response->headers;
|
||||
|
||||
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
|
||||
}
|
||||
|
||||
private static function componentFromJson(string $json): ?string
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (is_array($decoded)
|
||||
&& isset($decoded['component'])
|
||||
&& is_string($decoded['component'])
|
||||
&& $decoded['component'] !== '') {
|
||||
return $decoded['component'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function readContent(object $response): ?string
|
||||
{
|
||||
if (! method_exists($response, 'getContent')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var mixed $content */
|
||||
$content = $response->getContent();
|
||||
|
||||
return is_string($content) ? $content : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Enums;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
enum ReplayType
|
||||
{
|
||||
case None;
|
||||
case Pass;
|
||||
case Risky;
|
||||
case Skipped;
|
||||
case Incomplete;
|
||||
case Failure;
|
||||
|
||||
public static function fromStatus(?TestStatus $status): self
|
||||
{
|
||||
if (! $status instanceof TestStatus) {
|
||||
return self::None;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$status->isSuccess() => self::Pass,
|
||||
$status->isRisky() => self::Risky,
|
||||
$status->isSkipped() => self::Skipped,
|
||||
$status->isIncomplete() => self::Incomplete,
|
||||
default => self::Failure,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class FileState implements State
|
||||
{
|
||||
private readonly string $rootDir;
|
||||
|
||||
private ?string $resolvedRoot = null;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function read(string $key): ?string
|
||||
{
|
||||
$path = $this->pathFor($key);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = @file_get_contents($path);
|
||||
|
||||
return $bytes === false ? null : $bytes;
|
||||
}
|
||||
|
||||
public function write(string $key, string $content): bool
|
||||
{
|
||||
if (! $this->ensureRoot()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = $this->pathFor($key);
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $content) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$path = $this->pathFor($key);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return is_file($this->pathFor($key));
|
||||
}
|
||||
|
||||
public function keysWithPrefix(string $prefix): array
|
||||
{
|
||||
$root = $this->resolvedRoot();
|
||||
|
||||
if ($root === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
|
||||
$matches = glob($pattern);
|
||||
|
||||
if ($matches === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (str_ends_with($path, '.tmp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
public function pathFor(string $key): string
|
||||
{
|
||||
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
|
||||
}
|
||||
|
||||
private function resolvedRoot(): ?string
|
||||
{
|
||||
if ($this->resolvedRoot !== null) {
|
||||
return $this->resolvedRoot;
|
||||
}
|
||||
|
||||
$resolved = @realpath($this->rootDir);
|
||||
|
||||
if ($resolved === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resolvedRoot = $resolved;
|
||||
}
|
||||
|
||||
private function ensureRoot(): bool
|
||||
{
|
||||
if (is_dir($this->rootDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (@mkdir($this->rootDir, 0755, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_dir($this->rootDir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
private const int SCHEMA_VERSION = 18;
|
||||
|
||||
/**
|
||||
* @var array<int, class-string<Lockfile>>
|
||||
*/
|
||||
private const array LOCKFILES = [
|
||||
Lockfiles\PackageLock::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* structural: array<string, int|string|null>,
|
||||
* environmental: array<string, int|string|null>,
|
||||
* }
|
||||
*/
|
||||
public static function compute(string $projectRoot): array
|
||||
{
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
|
||||
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
'vite_config' => self::viteConfigHash($projectRoot),
|
||||
// 'package_json' => self::packageJsonHash($projectRoot),
|
||||
'package_lock' => self::packageLockHash($projectRoot),
|
||||
'js_config' => self::jsConfigHash($projectRoot),
|
||||
// 'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'environmental' => [
|
||||
'php_minor' => PHP_MAJOR_VERSION,
|
||||
|
||||
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
||||
// 'env_files' => self::envFilesHash($projectRoot),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
*/
|
||||
public static function structuralMatches(array $a, array $b): bool
|
||||
{
|
||||
$aStructural = self::structuralOnly($a);
|
||||
$bStructural = self::structuralOnly($b);
|
||||
|
||||
ksort($aStructural);
|
||||
ksort($bStructural);
|
||||
|
||||
return $aStructural === $bStructural;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function structuralDrift(array $stored, array $current): array
|
||||
{
|
||||
return self::detectDrift(
|
||||
self::structuralOnly($stored),
|
||||
self::structuralOnly($current),
|
||||
'schema',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $current
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function environmentalDrift(array $stored, array $current): array
|
||||
{
|
||||
return self::detectDrift(
|
||||
self::environmentalOnly($stored),
|
||||
self::environmentalOnly($current),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
|
||||
{
|
||||
$drifts = [];
|
||||
|
||||
foreach ($a as $key => $value) {
|
||||
if ($key === $skipKey) {
|
||||
continue;
|
||||
}
|
||||
if (($b[$key] ?? null) !== $value) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($b as $key => $value) {
|
||||
if ($key === $skipKey) {
|
||||
continue;
|
||||
}
|
||||
if (! array_key_exists($key, $a) && $value !== null) {
|
||||
$drifts[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($drifts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function structuralOnly(array $fingerprint): array
|
||||
{
|
||||
return self::bucket($fingerprint, 'structural');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function environmentalOnly(array $fingerprint): array
|
||||
{
|
||||
return self::bucket($fingerprint, 'environmental');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function bucket(array $fingerprint, string $key): array
|
||||
{
|
||||
$raw = $fingerprint[$key] ?? null;
|
||||
|
||||
if (! is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalised = [];
|
||||
|
||||
foreach ($raw as $k => $v) {
|
||||
if (is_string($k)) {
|
||||
$normalised[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
private static function viteConfigHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function jsConfigHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function composerLockHash(string $projectRoot): ?string
|
||||
{
|
||||
return self::trackedHash($projectRoot, 'composer.lock');
|
||||
}
|
||||
|
||||
private static function packageLockHash(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||
}
|
||||
|
||||
private static function lockfileHash(string $path, string $name): ?string
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (self::LOCKFILES as $class) {
|
||||
$handler = new $class;
|
||||
|
||||
if (! $handler->applies($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fingerprint = $handler->fingerprint($contents);
|
||||
|
||||
if ($fingerprint !== null) {
|
||||
return $fingerprint;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return hash('xxh128', $contents);
|
||||
}
|
||||
|
||||
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
||||
{
|
||||
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::hashIfExists($projectRoot.'/'.$relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the file exists and is not gitignored.
|
||||
*
|
||||
* Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
|
||||
* regenerate per-machine with OS-specific optional deps, which would
|
||||
* otherwise force a fingerprint mismatch on every fetched baseline.
|
||||
*/
|
||||
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
|
||||
{
|
||||
if (! is_file($projectRoot.'/'.$relativePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static $cache = [];
|
||||
|
||||
$key = $projectRoot."\0".$relativePath;
|
||||
|
||||
if (isset($cache[$key])) {
|
||||
return $cache[$key];
|
||||
}
|
||||
|
||||
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
|
||||
return $cache[$key] = true;
|
||||
}
|
||||
|
||||
$finder = (new Finder)
|
||||
->in($projectRoot)
|
||||
->depth('== 0')
|
||||
->name($relativePath)
|
||||
->ignoreVCSIgnored(true);
|
||||
|
||||
return $cache[$key] = $finder->hasResults();
|
||||
}
|
||||
|
||||
private static function contentHashOrNull(string $path): ?string
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = ContentHash::of($path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
private static function hashIfExists(string $path): ?string
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Symfony\Component\Process\ExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class JsModuleGraph
|
||||
{
|
||||
private const int NODE_TIMEOUT_SECONDS = 180;
|
||||
|
||||
private const string CACHE_FILE = 'js-module-graph.cache.json';
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
public const array VITE_CONFIG_NAMES = [
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
];
|
||||
|
||||
/**
|
||||
* Candidate page directories, in priority order. Must stay in sync with
|
||||
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array PAGE_DIR_CANDIDATES = [
|
||||
'resources/js/Pages',
|
||||
'resources/js/pages',
|
||||
'assets/js/Pages',
|
||||
'assets/js/pages',
|
||||
'assets/Pages',
|
||||
'assets/pages',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array PAGE_EXTENSIONS = [
|
||||
'vue', 'svelte',
|
||||
'tsx', 'jsx',
|
||||
'ts', 'js',
|
||||
'mts', 'cts', 'mjs', 'cjs',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public static function build(string $projectRoot): array
|
||||
{
|
||||
$result = self::resolve($projectRoot);
|
||||
|
||||
return $result ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
public static function buildStrict(string $projectRoot): ?array
|
||||
{
|
||||
return self::resolve($projectRoot);
|
||||
}
|
||||
|
||||
public static function isApplicable(string $projectRoot): bool
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::firstExistingPagesDir($projectRoot) !== null;
|
||||
}
|
||||
|
||||
private static function firstExistingPagesDir(string $projectRoot): ?string
|
||||
{
|
||||
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||
|
||||
if (! is_dir($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::dirHasPageFile($abs)) {
|
||||
return $abs;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function dirHasPageFile(string $dir): bool
|
||||
{
|
||||
try {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
);
|
||||
} catch (\UnexpectedValueException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function resolve(string $projectRoot): ?array
|
||||
{
|
||||
$fingerprint = self::fingerprint($projectRoot);
|
||||
|
||||
if ($fingerprint !== null) {
|
||||
$cached = self::readCache($projectRoot, $fingerprint);
|
||||
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$process = self::buildNodeProcess($projectRoot);
|
||||
|
||||
if (! $process instanceof Process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = self::parseNodeOutput($process->getOutput());
|
||||
|
||||
if ($result !== null && $fingerprint !== null) {
|
||||
self::writeCache($projectRoot, $fingerprint, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function buildNodeProcess(string $projectRoot): ?Process
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||
|
||||
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nodeBinary = (new ExecutableFinder)->find('node');
|
||||
|
||||
if ($nodeBinary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
|
||||
|
||||
if (! is_file($helperPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
|
||||
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function parseNodeOutput(string $output): ?array
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($output, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($decoded as $path => $components) {
|
||||
if (! is_string($path)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($components as $component) {
|
||||
if (is_string($component) && $component !== '') {
|
||||
$names[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
if ($names !== []) {
|
||||
sort($names);
|
||||
$out[$path] = $names;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function fingerprint(string $projectRoot): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stat = @stat($path);
|
||||
$bytes = @file_get_contents($path);
|
||||
|
||||
$parts[] = 'config:'.$name
|
||||
.':'.($stat === false ? '0' : (string) $stat['mtime'])
|
||||
.':'.($stat === false ? '0' : (string) $stat['size'])
|
||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$override = getenv('TIA_VITE_PAGES_DIR');
|
||||
|
||||
if (is_string($override) && $override !== '') {
|
||||
$parts[] = 'pagesDirOverride:'.$override;
|
||||
}
|
||||
|
||||
$pagesDir = self::firstExistingPagesDir($projectRoot);
|
||||
|
||||
if ($pagesDir !== null) {
|
||||
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
|
||||
}
|
||||
|
||||
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
|
||||
|
||||
if ($jsRoot !== null && is_dir($jsRoot)) {
|
||||
$entries = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
);
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $file->getPathname()
|
||||
.':'.$file->getSize()
|
||||
.':'.$file->getMTime();
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>|null
|
||||
*/
|
||||
private static function readCache(string $projectRoot, string $fingerprint): ?array
|
||||
{
|
||||
$path = self::cachePath($projectRoot);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graph = $decoded['graph'] ?? null;
|
||||
|
||||
if (! is_array($graph)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($graph as $key => $value) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
$names = [];
|
||||
|
||||
foreach ($value as $name) {
|
||||
if (is_string($name) && $name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
$out[$key] = $names;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<string>> $graph
|
||||
*/
|
||||
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
|
||||
{
|
||||
$path = self::cachePath($projectRoot);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode([
|
||||
'fingerprint' => $fingerprint,
|
||||
'graph' => $graph,
|
||||
]);
|
||||
|
||||
if ($payload === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
|
||||
|
||||
if (@file_put_contents($tmp, $payload) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
private static function cachePath(string $projectRoot): string
|
||||
{
|
||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Lockfiles;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class PackageLock implements Lockfile
|
||||
{
|
||||
public function applies(string $filename): bool
|
||||
{
|
||||
return $filename === 'package-lock.json';
|
||||
}
|
||||
|
||||
public function fingerprint(string $contents): ?string
|
||||
{
|
||||
$data = json_decode($contents, true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$packages = $data['packages'];
|
||||
|
||||
$platformPaths = [];
|
||||
|
||||
foreach ($packages as $path => $meta) {
|
||||
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
|
||||
$platformPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach ($packages as $path => $meta) {
|
||||
if (! is_string($path)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($meta)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isPlatformSpecific($meta)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
|
||||
continue;
|
||||
}
|
||||
$version = $meta['version'] ?? null;
|
||||
|
||||
$entries[$path] = is_string($version) ? $version : '';
|
||||
}
|
||||
|
||||
ksort($entries);
|
||||
|
||||
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $encoded === false ? null : hash('xxh128', $encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function isPlatformSpecific(array $meta): bool
|
||||
{
|
||||
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $platformPaths
|
||||
*/
|
||||
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
|
||||
{
|
||||
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Recorder
|
||||
{
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestFiles = [];
|
||||
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestTables = [];
|
||||
|
||||
/** @var array<string, array<string, true>> */
|
||||
private array $perTestInertiaComponents = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $perTestUsesDatabase = [];
|
||||
|
||||
/** @var array<string, string|null> */
|
||||
private array $classFileCache = [];
|
||||
|
||||
/** @var array<string, bool> */
|
||||
private array $classUsesDatabaseCache = [];
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $captureCoverage = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
|
||||
private bool $driverAvailable = false;
|
||||
|
||||
private string $driver = 'none';
|
||||
|
||||
private ?SourceScope $sourceScope = null;
|
||||
|
||||
public function activate(): void
|
||||
{
|
||||
$this->active = true;
|
||||
$this->captureCoverage = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable per-test link tracking (tables, Inertia components, database
|
||||
* usage, rendered views) without driving pcov/xdebug — for runs where
|
||||
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
||||
*/
|
||||
public function activateLinkTracking(): void
|
||||
{
|
||||
$this->active = true;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
}
|
||||
|
||||
public function driverAvailable(): bool
|
||||
{
|
||||
if (! $this->driverChecked) {
|
||||
if (function_exists('pcov\\start') && filter_var((string) ini_get('pcov.enabled'), FILTER_VALIDATE_BOOL)) {
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
$modes = \xdebug_info('mode');
|
||||
|
||||
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
||||
$this->driver = 'xdebug';
|
||||
$this->driverAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->driverChecked = true;
|
||||
}
|
||||
|
||||
return $this->driverAvailable;
|
||||
}
|
||||
|
||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->currentTestFile !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $this->resolveTestFile($className, $fallbackFile);
|
||||
|
||||
if ($file === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->currentTestFile = $file;
|
||||
|
||||
if ($this->classUsesDatabase($className)) {
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
\pcov\start();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
\xdebug_start_code_coverage();
|
||||
}
|
||||
|
||||
public function endTest(): void
|
||||
{
|
||||
if (! $this->active || $this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||
$this->currentTestFile = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\stop();
|
||||
|
||||
$scope = $this->sourceScope();
|
||||
$filesToCollectCoverageFor = [];
|
||||
|
||||
foreach (\pcov\waiting() as $file) {
|
||||
if (is_string($file) && $scope->contains($file)) {
|
||||
$filesToCollectCoverageFor[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$scope = $this->sourceScope();
|
||||
|
||||
foreach (array_keys($data) as $file) {
|
||||
if (! $scope->contains($file)) {
|
||||
unset($data[$file]);
|
||||
}
|
||||
}
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
}
|
||||
|
||||
foreach ($coveredFiles as $sourceFile) {
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
$this->currentTestFile = null;
|
||||
}
|
||||
|
||||
public function linkSource(string $sourceFile): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($sourceFile === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
private function classUsesDatabase(string $className): bool
|
||||
{
|
||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
||||
return $this->classUsesDatabaseCache[$className];
|
||||
}
|
||||
|
||||
if (! class_exists($className, false)) {
|
||||
return $this->classUsesDatabaseCache[$className] = false;
|
||||
}
|
||||
|
||||
static $needles = [
|
||||
'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
|
||||
'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
|
||||
'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
|
||||
];
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
do {
|
||||
foreach (array_keys($reflection->getTraits()) as $traitName) {
|
||||
if (isset($needles[$traitName])) {
|
||||
return $this->classUsesDatabaseCache[$className] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$reflection = $reflection->getParentClass();
|
||||
} while ($reflection !== false && ! $reflection->isInternal());
|
||||
|
||||
return $this->classUsesDatabaseCache[$className] = false;
|
||||
}
|
||||
|
||||
public function linkTable(string $table): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
|
||||
}
|
||||
|
||||
public function linkInertiaComponent(string $component): void
|
||||
{
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($component === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestFiles(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($this->perTestFiles as $testFile => $sources) {
|
||||
$out[$testFile] = array_keys($sources);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestTables(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($this->perTestTables as $testFile => $tables) {
|
||||
$names = array_keys($tables);
|
||||
sort($names);
|
||||
$out[$testFile] = $names;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function perTestInertiaComponents(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($this->perTestInertiaComponents as $testFile => $components) {
|
||||
$names = array_keys($components);
|
||||
sort($names);
|
||||
$out[$testFile] = $names;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string, true> */
|
||||
public function perTestUsesDatabase(): array
|
||||
{
|
||||
return $this->perTestUsesDatabase;
|
||||
}
|
||||
|
||||
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
||||
{
|
||||
if (array_key_exists($className, $this->classFileCache)) {
|
||||
$file = $this->classFileCache[$className];
|
||||
} else {
|
||||
$file = $this->readPestFilename($className);
|
||||
$this->classFileCache[$className] = $file;
|
||||
}
|
||||
|
||||
if ($file !== null) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) {
|
||||
return $fallbackFile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function readPestFilename(string $className): ?string
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||
|
||||
return $className::$__filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
private function filesWithExecutedLines(array $data): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $file => $lines) {
|
||||
if (! is_array($lines)) {
|
||||
continue;
|
||||
}
|
||||
$covered = [];
|
||||
foreach ($lines as $line => $count) {
|
||||
if (is_int($count) && $count > 0) {
|
||||
$covered[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if ($covered === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineKeys = array_keys($lines);
|
||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $file;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function sourceScope(): SourceScope
|
||||
{
|
||||
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentTestFile = null;
|
||||
$this->perTestFiles = [];
|
||||
$this->perTestTables = [];
|
||||
$this->perTestInertiaComponents = [];
|
||||
$this->perTestUsesDatabase = [];
|
||||
$this->classFileCache = [];
|
||||
$this->classUsesDatabaseCache = [];
|
||||
$this->sourceScope = null;
|
||||
$this->active = false;
|
||||
$this->captureCoverage = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ResultCollector
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
private ?float $startTime = null;
|
||||
|
||||
public function testPrepared(string $testId, ?string $testFile = null): void
|
||||
{
|
||||
$this->currentTestId = $testId;
|
||||
$this->currentTestFile = $testFile;
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
public function testPassed(): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::success());
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::failure($message));
|
||||
}
|
||||
|
||||
public function testErrored(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::error($message));
|
||||
}
|
||||
|
||||
public function testSkipped(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::skipped($message));
|
||||
}
|
||||
|
||||
public function testIncomplete(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::incomplete($message));
|
||||
}
|
||||
|
||||
public function testRisky(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::risky($message));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
public function recordAssertions(string $testId, int $assertions): void
|
||||
{
|
||||
if (isset($this->results[$testId])) {
|
||||
$this->results[$testId]['assertions'] = $assertions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
|
||||
*/
|
||||
public function merge(array $results): void
|
||||
{
|
||||
foreach ($results as $testId => $result) {
|
||||
$this->results[$testId] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->results = [];
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
public function finishTest(): void
|
||||
{
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function record(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$time = $this->startTime !== null
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 0.0;
|
||||
|
||||
$existing = $this->results[$this->currentTestId] ?? null;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
'status' => $status->asInt(),
|
||||
'message' => $status->message(),
|
||||
'time' => $time,
|
||||
'assertions' => $existing['assertions'] ?? 0,
|
||||
];
|
||||
|
||||
if ($this->currentTestFile !== null) {
|
||||
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use PHPUnit\TextUI\Configuration\Registry;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SourceScope
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $containsCache = [];
|
||||
|
||||
private const array TOP_LEVEL_NOISE = [
|
||||
'vendor',
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.idea',
|
||||
'.vscode',
|
||||
'.github',
|
||||
'.pest',
|
||||
'.phpunit.cache',
|
||||
'.cache',
|
||||
];
|
||||
|
||||
private const array NESTED_NOISE = [
|
||||
'storage/framework',
|
||||
'storage/logs',
|
||||
'bootstrap/cache',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $includes Absolute, normalised directory paths.
|
||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $includes,
|
||||
private readonly array $excludes,
|
||||
) {}
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
{
|
||||
$phpunitIncludes = [];
|
||||
$phpunitExcludes = [];
|
||||
|
||||
try {
|
||||
$source = Registry::get()->source();
|
||||
|
||||
foreach ($source->includeDirectories() as $dir) {
|
||||
$phpunitIncludes[] = self::normalise($dir->path());
|
||||
}
|
||||
|
||||
foreach ($source->excludeDirectories() as $dir) {
|
||||
$phpunitExcludes[] = self::normalise($dir->path());
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Registry not initialized — fall back to project-root scanning.
|
||||
}
|
||||
|
||||
$rootIncludes = self::topLevelProjectDirs($projectRoot);
|
||||
|
||||
$includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
|
||||
$excludes = array_values(array_unique([
|
||||
...$phpunitExcludes,
|
||||
...self::nestedNoiseDirs($projectRoot),
|
||||
]));
|
||||
|
||||
if ($includes === []) {
|
||||
$includes = [self::normalise($projectRoot)];
|
||||
}
|
||||
|
||||
return new self($includes, $excludes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
||||
*/
|
||||
public static function testPaths(): array
|
||||
{
|
||||
try {
|
||||
$suites = Registry::get()->testSuite();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($suites as $suite) {
|
||||
foreach ($suite->directories() as $directory) {
|
||||
$out[] = self::normalise($directory->path());
|
||||
}
|
||||
|
||||
foreach ($suite->files() as $file) {
|
||||
$out[] = self::normalise($file->path());
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
public function contains(string $absoluteFile): bool
|
||||
{
|
||||
if (isset($this->containsCache[$absoluteFile])) {
|
||||
return $this->containsCache[$absoluteFile];
|
||||
}
|
||||
|
||||
$real = @realpath($absoluteFile);
|
||||
$candidate = $real === false ? $absoluteFile : $real;
|
||||
$candidate = self::normalise($candidate);
|
||||
|
||||
foreach ($this->excludes as $excluded) {
|
||||
if ($this->startsWithDir($candidate, $excluded)) {
|
||||
return $this->containsCache[$absoluteFile] = false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->includes as $included) {
|
||||
if ($this->startsWithDir($candidate, $included)) {
|
||||
return $this->containsCache[$absoluteFile] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->containsCache[$absoluteFile] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function topLevelProjectDirs(string $projectRoot): array
|
||||
{
|
||||
$entries = @scandir($projectRoot);
|
||||
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($entry !== '' && $entry[0] === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (! is_dir($abs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = self::normalise(@realpath($abs) ?: $abs);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function nestedNoiseDirs(string $projectRoot): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (self::NESTED_NOISE as $relative) {
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
$out[] = self::normalise(@realpath($abs) ?: $abs);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function normalise(string $path): string
|
||||
{
|
||||
return rtrim($path, '/\\');
|
||||
}
|
||||
|
||||
private function startsWithDir(string $candidate, string $dir): bool
|
||||
{
|
||||
if ($candidate === $dir) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Storage
|
||||
{
|
||||
public static function tempDir(string $projectRoot): string
|
||||
{
|
||||
$home = self::homeDir();
|
||||
|
||||
if ($home === null) {
|
||||
return $projectRoot
|
||||
.DIRECTORY_SEPARATOR.'.pest'
|
||||
.DIRECTORY_SEPARATOR.'tia';
|
||||
}
|
||||
|
||||
return $home
|
||||
.DIRECTORY_SEPARATOR.'.pest'
|
||||
.DIRECTORY_SEPARATOR.'tia'
|
||||
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
||||
}
|
||||
|
||||
public static function purge(string $projectRoot): void
|
||||
{
|
||||
$dir = self::tempDir($projectRoot);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::removeRecursive($dir);
|
||||
}
|
||||
|
||||
private static function removeRecursive(string $dir): void
|
||||
{
|
||||
$entries = @scandir($dir);
|
||||
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
self::removeRecursive($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
private static function homeDir(): ?string
|
||||
{
|
||||
foreach (['HOME', 'USERPROFILE'] as $key) {
|
||||
$value = getenv($key);
|
||||
|
||||
if (is_string($value) && $value !== '' && is_dir($value)) {
|
||||
return rtrim($value, '/\\');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
|
||||
*/
|
||||
private static function projectKey(string $projectRoot): string
|
||||
{
|
||||
$origin = self::originIdentity($projectRoot);
|
||||
|
||||
$realpath = @realpath($projectRoot);
|
||||
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);
|
||||
|
||||
$hash = substr(hash('sha256', $input), 0, 16);
|
||||
$slug = self::slug(basename($projectRoot));
|
||||
|
||||
return $slug === '' ? $hash : $slug.'-'.$hash;
|
||||
}
|
||||
|
||||
private static function originIdentity(string $projectRoot): ?string
|
||||
{
|
||||
$url = self::rawOriginUrl($projectRoot);
|
||||
|
||||
if ($url === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// git@host:org/repo(.git)
|
||||
if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||
return strtolower($m[1].'/'.$m[2]);
|
||||
}
|
||||
|
||||
// scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file
|
||||
if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||
return strtolower($m[1].'/'.$m[2]);
|
||||
}
|
||||
|
||||
return strtolower($url);
|
||||
}
|
||||
|
||||
private static function rawOriginUrl(string $projectRoot): ?string
|
||||
{
|
||||
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||
|
||||
if (! is_file($config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($config);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) {
|
||||
return trim($match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function slug(string $name): string
|
||||
{
|
||||
$slug = strtolower($name);
|
||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
||||
|
||||
return trim($slug, '-');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TableExtractor
|
||||
{
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||
|
||||
/**
|
||||
* A single (optionally quoted) identifier segment.
|
||||
*/
|
||||
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||
|
||||
/**
|
||||
* @return list<string> Sorted, deduped table names referenced by the
|
||||
*/
|
||||
public static function fromSql(string $sql): array
|
||||
{
|
||||
$trimmed = ltrim($sql);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||
|
||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tables = [];
|
||||
|
||||
foreach ($matches[1] as $qualified) {
|
||||
$name = self::unqualified($qualified);
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
if (self::isSchemaMeta($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tables[strtolower($name)] = true;
|
||||
}
|
||||
|
||||
$out = array_map(strval(...), array_keys($tables));
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Table names referenced by `Schema::` calls,
|
||||
*/
|
||||
public static function fromMigrationSource(string $php): array
|
||||
{
|
||||
$tables = [];
|
||||
|
||||
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
||||
|
||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $i => $primary) {
|
||||
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||
|
||||
$secondary = $matches[2][$i] ?? '';
|
||||
if ($secondary !== '') {
|
||||
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||
|
||||
$sqlPatterns = [
|
||||
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
||||
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
||||
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
||||
];
|
||||
|
||||
foreach ($sqlPatterns as $pattern) {
|
||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower(self::unqualified($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower(self::lastDottedSegment($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_map(strval(...), array_keys($tables));
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The table segment of a possibly schema-qualified identifier chain,
|
||||
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
||||
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
||||
*/
|
||||
private static function unqualified(string $qualified): string
|
||||
{
|
||||
$name = '';
|
||||
|
||||
foreach (explode('.', $qualified) as $segment) {
|
||||
$segment = trim($segment, " \t\n\r\"`[]");
|
||||
|
||||
if ($segment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::isSchemaMeta($segment)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$name = $segment;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function lastDottedSegment(string $name): string
|
||||
{
|
||||
$position = strrpos($name, '.');
|
||||
|
||||
return $position === false ? $name : substr($name, $position + 1);
|
||||
}
|
||||
|
||||
private static function isSchemaMeta(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
|
||||
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|
||||
|| str_starts_with($lower, 'pg_')
|
||||
|| str_starts_with($lower, 'information_schema');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user