mirror of
https://github.com/pestphp/pest.git
synced 2026-04-20 22:20:17 +02:00
Compare commits
323 Commits
v3.5.2
...
c89493dd9b
| Author | SHA1 | Date | |
|---|---|---|---|
| c89493dd9b | |||
| 15035d37ef | |||
| 41f11c0ef3 | |||
| e91634ff05 | |||
| df0f440f84 | |||
| 50601e6118 | |||
| 247d59abf6 | |||
| b24c375d72 | |||
| 30fff116fd | |||
| 192f289e7e | |||
| 4b8e303cd5 | |||
| 87db0b4847 | |||
| 6ba373a772 | |||
| 945d476409 | |||
| a8cf0fe2cb | |||
| 2ae072bb95 | |||
| 59d066950c | |||
| 0dd1aa72ef | |||
| 4e03cd3edb | |||
| eeab24e2bb | |||
| 9b64d5425a | |||
| 0acab1cbb4 | |||
| e616eab9fb | |||
| 7cbb1fcdb2 | |||
| cb5f6e1bd2 | |||
| 985dadd934 | |||
| 10aee6045c | |||
| 4ac14b2528 | |||
| 13c322bab3 | |||
| 3855249ce9 | |||
| f528bd8427 | |||
| acd8aafa63 | |||
| e8d630e774 | |||
| b6385dc865 | |||
| 02dc8d7bcc | |||
| 729f18a152 | |||
| bdf60cea91 | |||
| 3a8ee8291c | |||
| 654cb726c9 | |||
| bce26aeaad | |||
| 5948bcd71e | |||
| 89006d83a9 | |||
| a8e974d64a | |||
| 617b074049 | |||
| 2eea71a664 | |||
| 4b5374d507 | |||
| 9085561ece | |||
| b71bfc513a | |||
| 75938ac9eb | |||
| e766825f5b | |||
| 8a83a1a1a9 | |||
| 109bb22c5e | |||
| 89dd212d84 | |||
| cd07c6d966 | |||
| 8dddb47ad5 | |||
| 3a6c2fab37 | |||
| 281dbf6cf4 | |||
| 40c8429058 | |||
| d9d46c73f8 | |||
| e44c554a0b | |||
| 9797a71dbc | |||
| c1a54df233 | |||
| ce05ee9aad | |||
| 3d2ebdb273 | |||
| f47b74445b | |||
| 6c42e7f4ea | |||
| be3ff37517 | |||
| a087555383 | |||
| 4b50cb486d | |||
| f7175ecfd7 | |||
| 07737bc0b2 | |||
| e6ab897594 | |||
| a753b41409 | |||
| 1a4c06bd6e | |||
| 5d42e8fe3a | |||
| 9d17b872dd | |||
| 2a80101f42 | |||
| f7015fe59c | |||
| 7281e0ded7 | |||
| 1675dd1d41 | |||
| df7b6c8454 | |||
| 5de8693e3b | |||
| 7d80f1d20e | |||
| b3119cc120 | |||
| 4e294edf76 | |||
| f96a1b2786 | |||
| a49cf7edc5 | |||
| b0f6a74cb6 | |||
| aaa226f6a6 | |||
| 69cb752d02 | |||
| cf00e58b7d | |||
| 1f39b28e2c | |||
| 9fcbca69d4 | |||
| b081584ab6 | |||
| 6966802afc | |||
| c61dcad42b | |||
| ec3e0b2d33 | |||
| c3620840b4 | |||
| 10a19f16ba | |||
| a956de5446 | |||
| 3a4329ddc7 | |||
| e6f511302b | |||
| dd01229d7b | |||
| c7e4efcea4 | |||
| df3205e814 | |||
| bc57a84e77 | |||
| bc39830d8a | |||
| 3a566b100e | |||
| 9fe61e0e56 | |||
| e86bec3e68 | |||
| 58b8f3cc5d | |||
| c157b661f2 | |||
| be90610f17 | |||
| 1701a306c3 | |||
| 064ab3fc2e | |||
| 44e315df98 | |||
| 62694c14b9 | |||
| 7c43c1c583 | |||
| 6a96aed654 | |||
| b1c997a869 | |||
| b4172e2c2e | |||
| ae419afd36 | |||
| 27aa305897 | |||
| 0e7c2abe8b | |||
| f5820bd670 | |||
| 41fd831153 | |||
| 51340439e8 | |||
| 1a39826935 | |||
| bd5fed9e12 | |||
| 26345fd9f4 | |||
| ae1da79ac1 | |||
| 00990efc97 | |||
| 477d20a54f | |||
| b7b16096db | |||
| 4105e33c39 | |||
| 08b09f2e98 | |||
| b0fab7e437 | |||
| 8e3444e1db | |||
| dc9a1e8ace | |||
| fc7a4182b5 | |||
| b7406938ac | |||
| 314caabd1d | |||
| 65cabf91b1 | |||
| f91c6c1e1e | |||
| 843dbbf18a | |||
| 47fb1d7763 | |||
| 639df4cb43 | |||
| e54e4a0178 | |||
| 7749775f50 | |||
| f11f3aa0a4 | |||
| 33817013fe | |||
| 7f11ace329 | |||
| 3d776f1f20 | |||
| d5ced0a5ca | |||
| af1e214be4 | |||
| 7f9b50974a | |||
| cd5272d8cc | |||
| a7b2039175 | |||
| 72cf695554 | |||
| 50960a96e9 | |||
| 507df757a1 | |||
| 8722b3fc3c | |||
| 19eca6e338 | |||
| 6b523d6963 | |||
| a350545803 | |||
| 71c2e97c9f | |||
| 98a12012bf | |||
| 027f4e4832 | |||
| 92523a6f39 | |||
| ed38fb644f | |||
| 39b66bf01d | |||
| 165c879fe6 | |||
| 4c8bf4b2fd | |||
| 1ee36f584d | |||
| 1b0a846a81 | |||
| e3e518747f | |||
| 0b96b8f630 | |||
| 711a60c2db | |||
| e7132fa012 | |||
| 3b72bbd7fe | |||
| 273edb864c | |||
| fcb60f3c4a | |||
| 91bb7589e2 | |||
| e524bf5f73 | |||
| 27414ce19f | |||
| fbc9e704e2 | |||
| ee6b3ed062 | |||
| 4c88590b89 | |||
| 66e59efec6 | |||
| f692be3637 | |||
| 127ad618d3 | |||
| da04ba62a8 | |||
| d187566e63 | |||
| 3e86e158b2 | |||
| d6c6489e93 | |||
| ee70a3cfea | |||
| 7a6f33f139 | |||
| 55218bcf78 | |||
| e29302300f | |||
| 2a47b514ec | |||
| 222ed174bc | |||
| 2aa32569f0 | |||
| 1d8d1a046f | |||
| 3d9ceb1cf2 | |||
| 520a5fe29d | |||
| de4409e368 | |||
| 6d6e4e040f | |||
| aac08629f7 | |||
| fe27012bbc | |||
| f9901245f1 | |||
| 21e22decf3 | |||
| e513f76ea9 | |||
| be9c95e3bc | |||
| 9172721ce8 | |||
| 924dc016cc | |||
| f49b91ec0d | |||
| 516ace85b4 | |||
| f9814793dd | |||
| 00572f5f8e | |||
| fb282b184e | |||
| e0695a13cb | |||
| 8f810bf2a2 | |||
| 84636cee96 | |||
| 0355119afc | |||
| 9d0410ee0b | |||
| 0d148c2a67 | |||
| 0f1e87c726 | |||
| 73bf579da3 | |||
| 5def62018b | |||
| d8e1b27491 | |||
| 2ff4713968 | |||
| 3f27352560 | |||
| af3fdceddb | |||
| 3faeede1ef | |||
| 0bc3219a2b | |||
| a22013a7d3 | |||
| 7fc69033f8 | |||
| ef76c04dbe | |||
| 7d77bbf1bb | |||
| 163479ae60 | |||
| c3bfdf130e | |||
| 8c403a57c2 | |||
| 97c136cd94 | |||
| d6cbd12d8b | |||
| 49bf00024f | |||
| dd44ac4195 | |||
| 5d2aafd2a3 | |||
| 0fc9d4dfe0 | |||
| 02b1ffb334 | |||
| c62cc3fef0 | |||
| 909d778da3 | |||
| 7711a52fe9 | |||
| 99c9f4e5d8 | |||
| a310796165 | |||
| db9243ca2e | |||
| 635e3b4c41 | |||
| 791734a29c | |||
| 8cfb0acf46 | |||
| bf67407ba5 | |||
| efdc84e115 | |||
| d1608bf33d | |||
| 4f6140fdb1 | |||
| 442a58d07f | |||
| 19e9267021 | |||
| c6244a8712 | |||
| eed68f2840 | |||
| 6080f51a0b | |||
| e0f07be017 | |||
| 42e1b9f17f | |||
| 0171617c1d | |||
| 2e11e9e65d | |||
| 4969526ef2 | |||
| d7b1c36fdd | |||
| 003fc96e8f | |||
| f68d11ccae | |||
| e46d499384 | |||
| 490f321a0d | |||
| 174645caa2 | |||
| ed70c9dc2b | |||
| 157a753d87 | |||
| 2c3a53f6cd | |||
| 0bdaef29e9 | |||
| 1ad30a97b3 | |||
| a5317c5640 | |||
| 66ceb64faa | |||
| fa4098db8d | |||
| 4a987d3d5c | |||
| 4079a08f5f | |||
| e4aab77a34 | |||
| 50ff347b59 | |||
| b5b8fab09b | |||
| c4c9e915f4 | |||
| e834527db2 | |||
| 23f130b0f9 | |||
| 0cb8c42497 | |||
| fe4b5e5e1f | |||
| 8ee9d66d80 | |||
| 7760d945bb | |||
| 709ecb1ba2 | |||
| 6afb36519d | |||
| 150bb9478d | |||
| bf3178473d | |||
| d2eb94d723 | |||
| 9688b83a3d | |||
| 675372c794 | |||
| c18636b3d5 | |||
| 1ac594bdf0 | |||
| 145294a4a3 | |||
| c2cabaeae6 | |||
| 918a8fc169 | |||
| 5d32dd0641 | |||
| 5331b44a18 | |||
| 53c94600cb | |||
| dd7d150caa | |||
| 92bc1decd9 | |||
| e3bfcbe5f1 | |||
| ba7eb70a5d | |||
| 74ff3b8cd9 | |||
| ab0b4a1b4e | |||
| 169b76458e | |||
| 668685498f | |||
| bab193e7e1 | |||
| f720be862e |
42
.github/workflows/static.yml
vendored
42
.github/workflows/static.yml
vendored
@ -2,9 +2,17 @@ name: Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
concurrency:
|
||||
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
static:
|
||||
@ -12,6 +20,7 @@ jobs:
|
||||
name: Static Tests
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
@ -19,26 +28,41 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.2
|
||||
php-version: 8.3
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.3-composer-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer update --prefer-stable --no-interaction --no-progress --ansi
|
||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||
|
||||
# - name: Type Check
|
||||
# run: composer test:type:check
|
||||
- name: Profanity Check
|
||||
run: composer test:profanity
|
||||
|
||||
- name: Type Check
|
||||
run: composer test:type:check
|
||||
|
||||
- name: Type Coverage
|
||||
run: composer test:type:coverage
|
||||
|
||||
- name: Refacto
|
||||
run: composer test:refacto
|
||||
|
||||
- name: Style
|
||||
run: composer test:lint
|
||||
|
||||
39
.github/workflows/tests.yml
vendored
39
.github/workflows/tests.yml
vendored
@ -2,26 +2,40 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
symfony: ['7.1']
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
dependency_version: [prefer-lowest, prefer-stable]
|
||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||
symfony: ['7.4', '8.0']
|
||||
php: ['8.3', '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@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@ -29,6 +43,21 @@ jobs:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
|
||||
${{ matrix.os }}-php-${{ matrix.php }}-composer-
|
||||
|
||||
- name: Setup Problem Matches
|
||||
run: |
|
||||
|
||||
14
Makefile
14
Makefile
@ -1,14 +0,0 @@
|
||||
# Well documented Makefiles
|
||||
DEFAULT_GOAL := help
|
||||
help:
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
|
||||
|
||||
build: ## Build all docker images. Specify the command e.g. via make build ARGS="--build-arg PHP=8.2"
|
||||
docker compose build $(ARGS)
|
||||
|
||||
##@ [Application]
|
||||
install: ## Install the composer dependencies
|
||||
docker compose run --rm composer install
|
||||
|
||||
test: ## Run the tests
|
||||
docker compose run --rm composer test
|
||||
43
README.md
43
README.md
@ -1,44 +1,53 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v3/banner.png" width="600" alt="PEST">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/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=3.x&label=Tests%203.x"></a>
|
||||
<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://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>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
------
|
||||
|
||||
> Pest v3 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest3-now-available)**.
|
||||
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
||||
|
||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||
|
||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||
- Follow us on Twitter at **[@pestphp »](https://twitter.com/pestphp)**
|
||||
- Join us at **[discord.gg/kaHY6p54JH »](https://discord.gg/kaHY6p54JH)** or **[t.me/+kYH5G4d5MV83ODk0 »](https://t.me/+kYH5G4d5MV83ODk0)**
|
||||
- Follow the creator Nuno Maduro:
|
||||
- YouTube: **[youtube.com/@nunomaduro](https://youtube.com/@nunomaduro)** — Videos every week
|
||||
- Twitch: **[twitch.tv/nunomaduro](https://twitch.tv/nunomaduro)** — Live coding on Mondays, Wednesdays, and Fridays at 9PM UTC
|
||||
- Twitter / X: **[x.com/enunomaduro](https://x.com/enunomaduro)**
|
||||
- LinkedIn: **[linkedin.com/in/nunomaduro](https://www.linkedin.com/in/nunomaduro)**
|
||||
- Instagram: **[instagram.com/enunomaduro](https://www.instagram.com/enunomaduro)**
|
||||
- Tiktok: **[tiktok.com/@enunomaduro](https://www.tiktok.com/@enunomaduro)**
|
||||
|
||||
## Sponsors
|
||||
|
||||
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)**
|
||||
- **[LaraJobs](https://larajobs.com)**
|
||||
- **[Brokerchooser](https://brokerchooser.com)**
|
||||
- **[Forge](https://forge.laravel.com)**
|
||||
- **[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)
|
||||
- [Codecourse](https://codecourse.com/?ref=pestphp)
|
||||
- [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp)
|
||||
- [Laracasts](https://laracasts.com/?ref=pestphp)
|
||||
- [Localazy](https://localazy.com/?ref=pestphp)
|
||||
- [Route4Me](https://www.route4me.com/?ref=pestphp)
|
||||
- [Spatie](https://spatie.be)
|
||||
- [Worksome](https://www.worksome.com/)
|
||||
- [Zapiet](https://www.zapiet.com/?ref=pestphp)
|
||||
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
|
||||
|
||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
When releasing a new version of Pest there are some checks and updates that need to be done:
|
||||
|
||||
> **For Pest v2 you should use the `2.x` branch instead.**
|
||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
||||
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 3.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...3.x](https://github.com/pestphp/pest/compare/{latest_version}...3.x)
|
||||
- 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)
|
||||
- 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"`
|
||||
|
||||
11
bin/pest
11
bin/pest
@ -10,6 +10,7 @@ use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\NotesTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\PrTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\FlakyTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\TodoTestCaseFilter;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
@ -23,6 +24,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
$dirty = false;
|
||||
$todo = false;
|
||||
$flaky = false;
|
||||
$notes = false;
|
||||
|
||||
foreach ($arguments as $key => $value) {
|
||||
@ -57,6 +59,11 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
unset($arguments[$key]);
|
||||
}
|
||||
|
||||
if ($value === '--flaky') {
|
||||
$flaky = true;
|
||||
unset($arguments[$key]);
|
||||
}
|
||||
|
||||
if ($value === '--notes') {
|
||||
$notes = true;
|
||||
unset($arguments[$key]);
|
||||
@ -150,6 +157,10 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter);
|
||||
}
|
||||
|
||||
if ($flaky) {
|
||||
$testSuite->tests->addTestCaseMethodFilter(new FlakyTestCaseFilter);
|
||||
}
|
||||
|
||||
if ($notes) {
|
||||
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
|
||||
}
|
||||
|
||||
@ -32,10 +32,13 @@ $bootPest = (static function (): void {
|
||||
'status-file:',
|
||||
'progress-file:',
|
||||
'unexpected-output-file:',
|
||||
'testresult-file:',
|
||||
'test-result-file:',
|
||||
'result-cache-file:',
|
||||
'teamcity-file:',
|
||||
'testdox-file:',
|
||||
'testdox-color',
|
||||
'testdox-columns:',
|
||||
'testdox-summary',
|
||||
'phpunit-argv:',
|
||||
]);
|
||||
|
||||
@ -61,7 +64,8 @@ $bootPest = (static function (): void {
|
||||
|
||||
assert(isset($getopt['progress-file']) && is_string($getopt['progress-file']));
|
||||
assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file']));
|
||||
assert(isset($getopt['testresult-file']) && is_string($getopt['testresult-file']));
|
||||
assert(isset($getopt['test-result-file']) && is_string($getopt['test-result-file']));
|
||||
assert(! isset($getopt['result-cache-file']) || is_string($getopt['result-cache-file']));
|
||||
assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file']));
|
||||
assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file']));
|
||||
|
||||
@ -77,11 +81,12 @@ $bootPest = (static function (): void {
|
||||
$phpunitArgv,
|
||||
$getopt['progress-file'],
|
||||
$getopt['unexpected-output-file'],
|
||||
$getopt['testresult-file'],
|
||||
$getopt['test-result-file'],
|
||||
$getopt['result-cache-file'] ?? null,
|
||||
$getopt['teamcity-file'] ?? null,
|
||||
$getopt['testdox-file'] ?? null,
|
||||
isset($getopt['testdox-color']),
|
||||
$getopt['testdox-columns'] ?? null,
|
||||
(int) ($getopt['testdox-columns'] ?? null),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
|
||||
@ -17,19 +17,21 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2.0",
|
||||
"brianium/paratest": "^7.6.0",
|
||||
"nunomaduro/collision": "^8.5.0",
|
||||
"nunomaduro/termwind": "^2.3.0",
|
||||
"pestphp/pest-plugin": "^3.0.0",
|
||||
"pestphp/pest-plugin-arch": "^3.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^3.0.5",
|
||||
"phpunit/phpunit": "^11.4.4"
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"nunomaduro/collision": "^8.9.3",
|
||||
"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.20",
|
||||
"symfony/process": "^7.4.8|^8.0.8"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.16.0",
|
||||
"phpunit/phpunit": ">11.4.4",
|
||||
"sebastian/exporter": "<6.0.0",
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.20",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
"autoload": {
|
||||
@ -48,14 +50,19 @@
|
||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||
"Tests\\": "tests/PHPUnit/"
|
||||
},
|
||||
"classmap": [
|
||||
"tests/Fixtures/Arch/ToBeCasedCorrectly/IncorrectCasing/incorrectCasing.php"
|
||||
],
|
||||
"files": [
|
||||
"tests/Autoload.php"
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest-dev-tools": "^3.3.0",
|
||||
"pestphp/pest-plugin-type-coverage": "^3.2.0",
|
||||
"symfony/process": "^7.1.8"
|
||||
"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"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
@ -70,19 +77,23 @@
|
||||
"bin/pest"
|
||||
],
|
||||
"scripts": {
|
||||
"refacto": "rector",
|
||||
"lint": "pint",
|
||||
"test:refacto": "rector --dry-run",
|
||||
"test:lint": "pint --test",
|
||||
"lint": [
|
||||
"rector",
|
||||
"pint --parallel"
|
||||
],
|
||||
"test:lint": [
|
||||
"rector --dry-run",
|
||||
"pint --parallel --test"
|
||||
],
|
||||
"test:profanity": "php bin/pest --profanity --compact",
|
||||
"test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug",
|
||||
"test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100",
|
||||
"test:unit": "php bin/pest --colors=always --exclude-group=integration --compact",
|
||||
"test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --colors=always --group=integration -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots",
|
||||
"test:unit": "php bin/pest --exclude-group=integration --compact",
|
||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --group=integration -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||
"test": [
|
||||
"@test:refacto",
|
||||
"@test:lint",
|
||||
"@test:type:check",
|
||||
"@test:type:coverage",
|
||||
@ -111,6 +122,8 @@
|
||||
"Pest\\Plugins\\Snapshot",
|
||||
"Pest\\Plugins\\Verbose",
|
||||
"Pest\\Plugins\\Version",
|
||||
"Pest\\Plugins\\Shard",
|
||||
"Pest\\Plugins\\Tia",
|
||||
"Pest\\Plugins\\Parallel"
|
||||
]
|
||||
},
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: ./docker
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
composer:
|
||||
build:
|
||||
context: ./docker
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
entrypoint: ["composer"]
|
||||
@ -52,6 +52,8 @@ use PHPUnit\Util\Filter;
|
||||
use PHPUnit\Util\ThrowableToStringMapper;
|
||||
|
||||
/**
|
||||
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*/
|
||||
final readonly class ThrowableBuilder
|
||||
@ -68,7 +70,7 @@ final readonly class ThrowableBuilder
|
||||
$previous = self::from($previous);
|
||||
}
|
||||
|
||||
$trace = Filter::getFilteredStacktrace($t);
|
||||
$trace = Filter::stackTraceFromThrowableAsString($t);
|
||||
|
||||
if ($t instanceof RenderableOnCollisionEditor && $frame = $t->toCollisionEditor()) {
|
||||
$file = $frame->getFile();
|
||||
@ -82,7 +84,7 @@ final readonly class ThrowableBuilder
|
||||
$t->getMessage(),
|
||||
ThrowableToStringMapper::map($t),
|
||||
$trace,
|
||||
$previous
|
||||
$previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,39 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of PHPUnit.
|
||||
*
|
||||
@ -14,6 +47,9 @@ namespace PHPUnit\Logging\JUnit;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use Pest\Logging\Converter;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\EventFacadeIsSealedException;
|
||||
@ -27,6 +63,7 @@ use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\PreparationStarted;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\TestSuite\Started;
|
||||
use PHPUnit\Event\UnknownSubscriberTypeException;
|
||||
@ -41,13 +78,15 @@ use function str_replace;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*/
|
||||
final class JunitXmlLogger
|
||||
{
|
||||
private readonly Printer $printer;
|
||||
|
||||
private readonly \Pest\Logging\Converter $converter; // pest-added
|
||||
private readonly Converter $converter; // pest-added
|
||||
|
||||
private DOMDocument $document;
|
||||
|
||||
@ -59,32 +98,32 @@ final class JunitXmlLogger
|
||||
private array $testSuites = [];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteTests = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteAssertions = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteErrors = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteFailures = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteSkipped = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteTimes = [0];
|
||||
|
||||
@ -105,7 +144,7 @@ final class JunitXmlLogger
|
||||
public function __construct(Printer $printer, Facade $facade)
|
||||
{
|
||||
$this->printer = $printer;
|
||||
$this->converter = new \Pest\Logging\Converter(\Pest\Support\Container::getInstance()->get(\Pest\TestSuite::class)->rootPath); // pest-added
|
||||
$this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath); // pest-added
|
||||
|
||||
$this->registerSubscribers($facade);
|
||||
$this->createDocument();
|
||||
@ -113,7 +152,7 @@ final class JunitXmlLogger
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->printer->print($this->document->saveXML());
|
||||
$this->printer->print($this->document->saveXML() ?: '');
|
||||
|
||||
$this->printer->flush();
|
||||
}
|
||||
@ -195,28 +234,34 @@ final class JunitXmlLogger
|
||||
$this->createTestCase($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function testPreparationFailed(): void
|
||||
{
|
||||
$this->preparationFailed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function testPrepared(): void
|
||||
{
|
||||
$this->prepared = true;
|
||||
}
|
||||
|
||||
public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $event): void
|
||||
{
|
||||
assert($this->currentTestCase !== null);
|
||||
|
||||
$systemOut = $this->document->createElement(
|
||||
'system-out',
|
||||
Xml::prepareString($event->output()),
|
||||
);
|
||||
|
||||
$this->currentTestCase->appendChild($systemOut);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function testFinished(Finished $event): void
|
||||
{
|
||||
if ($this->preparationFailed) {
|
||||
if (! $this->prepared || $this->preparationFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -305,9 +350,11 @@ final class JunitXmlLogger
|
||||
new TestPreparationStartedSubscriber($this),
|
||||
new TestPreparationFailedSubscriber($this),
|
||||
new TestPreparedSubscriber($this),
|
||||
new TestPrintedUnexpectedOutputSubscriber($this),
|
||||
new TestFinishedSubscriber($this),
|
||||
new TestErroredSubscriber($this),
|
||||
new TestFailedSubscriber($this),
|
||||
new TestMarkedIncompleteSubscriber($this),
|
||||
new TestSkippedSubscriber($this),
|
||||
new TestRunnerExecutionFinishedSubscriber($this),
|
||||
);
|
||||
@ -431,7 +478,7 @@ final class JunitXmlLogger
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*
|
||||
* @psalm-assert !null $this->currentTestCase
|
||||
* @phpstan-assert !null $this->currentTestCase
|
||||
*/
|
||||
private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void
|
||||
{
|
||||
@ -446,7 +493,7 @@ final class JunitXmlLogger
|
||||
if ($test->isTestMethod()) {
|
||||
assert($test instanceof TestMethod);
|
||||
|
||||
//$testCase->setAttribute('line', (string) $test->line()); // pest-removed
|
||||
// $testCase->setAttribute('line', (string) $test->line()); // pest-removed
|
||||
$className = $this->converter->getTrimmedTestClassName($test); // pest-added
|
||||
$testCase->setAttribute('class', $className); // pest-changed
|
||||
$testCase->setAttribute('classname', str_replace('\\', '.', $className)); // pest-changed
|
||||
|
||||
@ -99,7 +99,9 @@ abstract class NameFilterIterator extends RecursiveFilterIterator
|
||||
}
|
||||
|
||||
if ($test instanceof HasPrintableTestCaseName) {
|
||||
$name = $test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName();
|
||||
$name = trim(
|
||||
$test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName().$test->dataSetAsString()
|
||||
);
|
||||
} else {
|
||||
$name = $test::class.'::'.$test->nameWithDataSet();
|
||||
}
|
||||
|
||||
@ -46,9 +46,10 @@ declare(strict_types=1);
|
||||
namespace PHPUnit\Runner\ResultCache;
|
||||
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
use const LOCK_EX;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use PHPUnit\Runner\DirectoryCannotBeCreatedException;
|
||||
use PHPUnit\Runner\DirectoryDoesNotExistException;
|
||||
use PHPUnit\Runner\Exception;
|
||||
use PHPUnit\Util\Filesystem;
|
||||
|
||||
@ -65,24 +66,23 @@ use function json_encode;
|
||||
use function Pest\version;
|
||||
|
||||
/**
|
||||
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*/
|
||||
final class DefaultResultCache implements ResultCache
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
|
||||
private const string DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
|
||||
|
||||
private readonly string $cacheFilename;
|
||||
|
||||
/**
|
||||
* @psalm-var array<string, TestStatus>
|
||||
* @var array<string, TestStatus>
|
||||
*/
|
||||
private array $defects = [];
|
||||
|
||||
/**
|
||||
* @psalm-var array<string, float>
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private array $times = [];
|
||||
|
||||
@ -95,28 +95,39 @@ final class DefaultResultCache implements ResultCache
|
||||
$this->cacheFilename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_RESULT_CACHE_FILENAME;
|
||||
}
|
||||
|
||||
public function setStatus(string $id, TestStatus $status): void
|
||||
public function setStatus(ResultCacheId $id, TestStatus $status): void
|
||||
{
|
||||
if ($status->isSuccess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->defects[$id] = $status;
|
||||
$this->defects[$id->asString()] = $status;
|
||||
}
|
||||
|
||||
public function status(string $id): TestStatus
|
||||
public function status(ResultCacheId $id): TestStatus
|
||||
{
|
||||
return $this->defects[$id] ?? TestStatus::unknown();
|
||||
return $this->defects[$id->asString()] ?? TestStatus::unknown();
|
||||
}
|
||||
|
||||
public function setTime(string $id, float $time): void
|
||||
public function setTime(ResultCacheId $id, float $time): void
|
||||
{
|
||||
$this->times[$id] = $time;
|
||||
$this->times[$id->asString()] = $time;
|
||||
}
|
||||
|
||||
public function time(string $id): float
|
||||
public function time(ResultCacheId $id): float
|
||||
{
|
||||
return $this->times[$id] ?? 0.0;
|
||||
return $this->times[$id->asString()] ?? 0.0;
|
||||
}
|
||||
|
||||
public function mergeWith(self $other): void
|
||||
{
|
||||
foreach ($other->defects as $id => $defect) {
|
||||
$this->defects[$id] = $defect;
|
||||
}
|
||||
|
||||
foreach ($other->times as $id => $time) {
|
||||
$this->times[$id] = $time;
|
||||
}
|
||||
}
|
||||
|
||||
public function load(): void
|
||||
@ -165,7 +176,7 @@ final class DefaultResultCache implements ResultCache
|
||||
public function persist(): void
|
||||
{
|
||||
if (! Filesystem::createDirectory(dirname($this->cacheFilename))) {
|
||||
throw new DirectoryCannotBeCreatedException($this->cacheFilename);
|
||||
throw new DirectoryDoesNotExistException(dirname($this->cacheFilename));
|
||||
}
|
||||
|
||||
$data = [
|
||||
|
||||
388
overrides/Runner/TestSuiteSorter.php
Normal file
388
overrides/Runner/TestSuiteSorter.php
Normal file
@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of PHPUnit.
|
||||
*
|
||||
* (c) Sebastian Bergmann <sebastian@phpunit.de>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace PHPUnit\Runner;
|
||||
|
||||
use PHPUnit\Framework\DataProviderTestSuite;
|
||||
use PHPUnit\Framework\Reorderable;
|
||||
use PHPUnit\Framework\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestSuite;
|
||||
use PHPUnit\Runner\ResultCache\NullResultCache;
|
||||
use PHPUnit\Runner\ResultCache\ResultCache;
|
||||
use PHPUnit\Runner\ResultCache\ResultCacheId;
|
||||
|
||||
use function array_diff;
|
||||
use function array_merge;
|
||||
use function array_reverse;
|
||||
use function array_splice;
|
||||
use function assert;
|
||||
use function count;
|
||||
use function in_array;
|
||||
use function max;
|
||||
use function shuffle;
|
||||
use function usort;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*/
|
||||
final class TestSuiteSorter
|
||||
{
|
||||
public const int ORDER_DEFAULT = 0;
|
||||
|
||||
public const int ORDER_RANDOMIZED = 1;
|
||||
|
||||
public const int ORDER_REVERSED = 2;
|
||||
|
||||
public const int ORDER_DEFECTS_FIRST = 3;
|
||||
|
||||
public const int ORDER_DURATION = 4;
|
||||
|
||||
public const int ORDER_SIZE = 5;
|
||||
|
||||
/**
|
||||
* @var non-empty-array<non-empty-string, positive-int>
|
||||
*/
|
||||
private const array SIZE_SORT_WEIGHT = [
|
||||
'small' => 1,
|
||||
'medium' => 2,
|
||||
'large' => 3,
|
||||
'unknown' => 4,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, int> Associative array of (string => DEFECT_SORT_WEIGHT) elements
|
||||
*/
|
||||
private array $defectSortOrder = [];
|
||||
|
||||
private readonly ResultCache $cache;
|
||||
|
||||
public function __construct(?ResultCache $cache = null)
|
||||
{
|
||||
$this->cache = $cache ?? new NullResultCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void
|
||||
{
|
||||
$allowedOrders = [
|
||||
self::ORDER_DEFAULT,
|
||||
self::ORDER_REVERSED,
|
||||
self::ORDER_RANDOMIZED,
|
||||
self::ORDER_DURATION,
|
||||
self::ORDER_SIZE,
|
||||
];
|
||||
|
||||
if (! in_array($order, $allowedOrders, true)) {
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new InvalidOrderException;
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
$allowedOrderDefects = [
|
||||
self::ORDER_DEFAULT,
|
||||
self::ORDER_DEFECTS_FIRST,
|
||||
];
|
||||
|
||||
if (! in_array($orderDefects, $allowedOrderDefects, true)) {
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new InvalidOrderException;
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
if ($suite instanceof TestSuite) {
|
||||
foreach ($suite as $_suite) {
|
||||
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
|
||||
}
|
||||
|
||||
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
|
||||
$this->addSuiteToDefectSortOrder($suite);
|
||||
}
|
||||
|
||||
$this->sort($suite, $order, $resolveDependencies, $orderDefects);
|
||||
}
|
||||
}
|
||||
|
||||
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
|
||||
{
|
||||
if ($suite->tests() === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($order === self::ORDER_REVERSED) {
|
||||
$suite->setTests($this->reverse($suite->tests()));
|
||||
} elseif ($order === self::ORDER_RANDOMIZED) {
|
||||
$suite->setTests($this->randomize($suite->tests()));
|
||||
} elseif ($order === self::ORDER_DURATION) {
|
||||
$suite->setTests($this->sortByDuration($suite->tests()));
|
||||
} elseif ($order === self::ORDER_SIZE) {
|
||||
$suite->setTests($this->sortBySize($suite->tests()));
|
||||
}
|
||||
|
||||
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
|
||||
$suite->setTests($this->sortDefectsFirst($suite->tests()));
|
||||
}
|
||||
|
||||
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
|
||||
$tests = $suite->tests();
|
||||
|
||||
/** @noinspection PhpParamsInspection */
|
||||
/** @phpstan-ignore argument.type */
|
||||
$suite->setTests($this->resolveDependencies($tests));
|
||||
}
|
||||
}
|
||||
|
||||
private function addSuiteToDefectSortOrder(TestSuite $suite): void
|
||||
{
|
||||
$max = 0;
|
||||
|
||||
foreach ($suite->tests() as $test) {
|
||||
assert($test instanceof Reorderable);
|
||||
|
||||
$sortId = $test->sortId();
|
||||
|
||||
if (! isset($this->defectSortOrder[$sortId])) {
|
||||
$this->defectSortOrder[$sortId] = $this->cache->status(ResultCacheId::fromReorderable($test))->asInt();
|
||||
$max = max($max, $this->defectSortOrder[$sortId]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->defectSortOrder[$suite->sortId()] = $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function reverse(array $tests): array
|
||||
{
|
||||
return array_reverse($tests);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function randomize(array $tests): array
|
||||
{
|
||||
shuffle($tests);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function sortDefectsFirst(array $tests): array
|
||||
{
|
||||
usort(
|
||||
$tests,
|
||||
fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right),
|
||||
);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function sortByDuration(array $tests): array
|
||||
{
|
||||
usort(
|
||||
$tests,
|
||||
fn (Test $left, Test $right) => $this->cmpDuration($left, $right),
|
||||
);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function sortBySize(array $tests): array
|
||||
{
|
||||
usort(
|
||||
$tests,
|
||||
fn (Test $left, Test $right) => $this->cmpSize($left, $right),
|
||||
);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator callback function to sort tests for "reach failure as fast as possible".
|
||||
*
|
||||
* 1. sort tests by defect weight defined in self::DEFECT_SORT_WEIGHT
|
||||
* 2. when tests are equally defective, sort the fastest to the front
|
||||
* 3. do not reorder successful tests
|
||||
*/
|
||||
private function cmpDefectPriorityAndTime(Test $a, Test $b): int
|
||||
{
|
||||
assert($a instanceof Reorderable);
|
||||
assert($b instanceof Reorderable);
|
||||
|
||||
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
|
||||
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
|
||||
|
||||
if ($priorityA !== $priorityB) {
|
||||
// Sort defect weight descending
|
||||
return $priorityB <=> $priorityA;
|
||||
}
|
||||
|
||||
if ($priorityA > 0 || $priorityB > 0) {
|
||||
return $this->cmpDuration($a, $b);
|
||||
}
|
||||
|
||||
// do not change execution order
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares test duration for sorting tests by duration ascending.
|
||||
*/
|
||||
private function cmpDuration(Test $a, Test $b): int
|
||||
{
|
||||
if (! ($a instanceof Reorderable && $b instanceof Reorderable)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares test size for sorting tests small->medium->large->unknown.
|
||||
*/
|
||||
private function cmpSize(Test $a, Test $b): int
|
||||
{
|
||||
$sizeA = ($a instanceof TestCase || $a instanceof DataProviderTestSuite)
|
||||
? $a->size()->asString()
|
||||
: 'unknown';
|
||||
$sizeB = ($b instanceof TestCase || $b instanceof DataProviderTestSuite)
|
||||
? $b->size()->asString()
|
||||
: 'unknown';
|
||||
|
||||
return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible.
|
||||
* The algorithm will leave the tests in original running order when it can.
|
||||
* For more details see the documentation for test dependencies.
|
||||
*
|
||||
* Short description of algorithm:
|
||||
* 1. Pick the next Test from remaining tests to be checked for dependencies.
|
||||
* 2. If the test has no dependencies: mark done, start again from the top
|
||||
* 3. If the test has dependencies but none left to do: mark done, start again from the top
|
||||
* 4. When we reach the end add any leftover tests to the end. These will be marked 'skipped' during execution.
|
||||
*
|
||||
* @param array<TestCase> $tests
|
||||
* @return array<TestCase>
|
||||
*/
|
||||
private function resolveDependencies(array $tests): array
|
||||
{
|
||||
// Pest: Fast-path. If no test in this suite declares dependencies, the
|
||||
// original O(N^2) algorithm is wasted work — it would splice each test
|
||||
// one-by-one back into the same order. The check deliberately walks
|
||||
// TestCase instances directly instead of calling TestSuite::requires(),
|
||||
// because the latter lazily builds TestSuite::provides() via
|
||||
// ExecutionOrderDependency::mergeUnique, which is O(N^2) in the total
|
||||
// number of tests. With thousands of tests that single call alone can
|
||||
// burn several seconds before the sort even begins. Reading the
|
||||
// cached TestCase::$dependencies property stays O(N) and costs nothing
|
||||
// when no test uses `->depends()` / PHPUnit `@depends`.
|
||||
if (! $this->anyTestHasDependencies($tests)) {
|
||||
return $tests;
|
||||
}
|
||||
|
||||
$newTestOrder = [];
|
||||
$i = 0;
|
||||
$provided = [];
|
||||
|
||||
do {
|
||||
if (array_diff($tests[$i]->requires(), $provided) === []) {
|
||||
$provided = array_merge($provided, $tests[$i]->provides());
|
||||
$newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1));
|
||||
$i = 0;
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
} while ($tests !== [] && ($i < count($tests)));
|
||||
|
||||
return array_merge($newTestOrder, $tests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheaply determines whether any test in the tree declares @depends.
|
||||
*
|
||||
* Walks `TestSuite` containers recursively and inspects each `TestCase`
|
||||
* directly so it never triggers `TestSuite::provides()`, which is O(N^2)
|
||||
* in the total number of aggregated tests.
|
||||
*
|
||||
* @param iterable<Test> $tests
|
||||
*/
|
||||
private function anyTestHasDependencies(iterable $tests): bool
|
||||
{
|
||||
foreach ($tests as $test) {
|
||||
if ($test instanceof TestSuite) {
|
||||
if ($this->anyTestHasDependencies($test->tests())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($test instanceof TestCase && $test->requires() !== []) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,7 @@ declare(strict_types=1);
|
||||
namespace PHPUnit\TextUI;
|
||||
|
||||
use Pest\Plugins\Only;
|
||||
use Pest\Runner\Filter\EnsureTestCaseIsInitiatedFilter;
|
||||
use PHPUnit\Event;
|
||||
use PHPUnit\Framework\TestSuite;
|
||||
use PHPUnit\Runner\Filter\Factory;
|
||||
@ -66,6 +67,12 @@ final readonly class TestSuiteFilterProcessor
|
||||
{
|
||||
$factory = new Factory;
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
(fn () => $this->filters[] = [
|
||||
'className' => EnsureTestCaseIsInitiatedFilter::class,
|
||||
'argument' => '',
|
||||
])->call($factory);
|
||||
|
||||
if (! $configuration->hasFilter() &&
|
||||
! $configuration->hasGroups() &&
|
||||
! $configuration->hasExcludeGroups() &&
|
||||
@ -73,6 +80,8 @@ final readonly class TestSuiteFilterProcessor
|
||||
! $configuration->hasTestsCovering() &&
|
||||
! $configuration->hasTestsUsing() &&
|
||||
! Only::isEnabled()) {
|
||||
$suite->injectFilter($factory);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
121
phpstan-baseline.neon
Normal file
121
phpstan-baseline.neon
Normal file
@ -0,0 +1,121 @@
|
||||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#'
|
||||
identifier: trait.unused
|
||||
count: 1
|
||||
path: src/Concerns/Expectable.php
|
||||
|
||||
-
|
||||
message: '#^Trait Pest\\Concerns\\Logging\\WritesToConsole is used zero times and is not analysed\.$#'
|
||||
identifier: trait.unused
|
||||
count: 1
|
||||
path: src/Concerns/Logging/WritesToConsole.php
|
||||
|
||||
-
|
||||
message: '#^Trait Pest\\Concerns\\Testable is used zero times and is not analysed\.$#'
|
||||
identifier: trait.unused
|
||||
count: 1
|
||||
path: src/Concerns/Testable.php
|
||||
|
||||
-
|
||||
message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\<TAndValue\> but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\<TAndValue of mixed\>\.$#'
|
||||
identifier: return.type
|
||||
count: 1
|
||||
path: src/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$each contains generic class Pest\\Expectations\\EachExpectation but does not specify its types\: TValue$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: src/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$not contains generic class Pest\\Expectations\\OppositeExpectation but does not specify its types\: TValue$#'
|
||||
identifier: missingType.generics
|
||||
count: 1
|
||||
path: src/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#2 \$newScope of method Closure\:\:bindTo\(\) expects ''static''\|class\-string\|object\|null, string given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^Function expect\(\) should return Pest\\Expectation\<TValue\|null\> but returns Pest\\Expectation\<TValue\|null\>\.$#'
|
||||
identifier: return.type
|
||||
count: 1
|
||||
path: src/Functions.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$argv of method PHPUnit\\TextUI\\Application\:\:run\(\) expects list\<string\>, array\<int, string\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/Kernel.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method object&TValue of mixed\:\:__toString\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/Mixins/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method object&TValue of mixed\:\:toArray\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: src/Mixins/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method object&TValue of mixed\:\:toSnapshot\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/Mixins/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method object&TValue of mixed\:\:toString\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/Mixins/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
|
||||
identifier: staticMethod.alreadyNarrowedType
|
||||
count: 2
|
||||
path: src/Mixins/Expectation.php
|
||||
|
||||
-
|
||||
message: '#^PHPDoc tag @var with type callable\(\)\: bool is not subtype of native type Closure\|null\.$#'
|
||||
identifier: varTag.nativeType
|
||||
count: 1
|
||||
path: src/PendingCalls/TestCall.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$testErroredEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\AfterLastTestMethodErrored\|PHPUnit\\Event\\Test\\BeforeFirstTestMethodErrored\|PHPUnit\\Event\\Test\\Errored\>, array given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestSuite\\Skipped\>, array given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#8 \$testSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\Skipped\>, array given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#9 \$testMarkedIncompleteEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\MarkedIncomplete\>, array given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
||||
|
||||
-
|
||||
message: '#^Property Pest\\Plugins\\Parallel\\Paratest\\WrapperRunner\:\:\$pending \(list\<non\-empty\-string\>\) does not accept array\<int, non\-empty\-string\>\.$#'
|
||||
identifier: assign.propertyType
|
||||
count: 1
|
||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
||||
5
phpstan-pest-extension.neon
Normal file
5
phpstan-pest-extension.neon
Normal file
@ -0,0 +1,5 @@
|
||||
services:
|
||||
-
|
||||
class: Pest\PHPStan\HigherOrderExpectationTypeExtension
|
||||
tags:
|
||||
- phpstan.broker.expressionTypeResolverExtension
|
||||
13
phpstan.neon
13
phpstan.neon
@ -1,14 +1,11 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-strict-rules/rules.neon
|
||||
- vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
|
||||
- phpstan-baseline.neon
|
||||
- phpstan-pest-extension.neon
|
||||
- vendor/mrpunyapal/peststan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: max
|
||||
level: 7
|
||||
paths:
|
||||
- src
|
||||
|
||||
checkMissingIterableValueType: true
|
||||
reportUnmatchedIgnoredErrors: true
|
||||
|
||||
ignoreErrors:
|
||||
- "#type mixed is not subtype of native#"
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory suffix=".php">./tests</directory>
|
||||
<directory suffix=".php">./tests-external</directory>
|
||||
<exclude>./tests/.snapshots</exclude>
|
||||
<exclude>./tests/.tests</exclude>
|
||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
@ -12,6 +15,9 @@ return RectorConfig::configure()
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
NarrowObjectReturnTypeRector::class,
|
||||
RemoveParentDelegatingConstructorRector::class,
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
|
||||
22
resources/views/installers/plugin-browser.php
Normal file
22
resources/views/installers/plugin-browser.php
Normal file
@ -0,0 +1,22 @@
|
||||
<div class="mx-2 mb-1">
|
||||
<p>
|
||||
<span>Using the <span class="text-yellow font-bold">visit()</span> function requires the Pest Plugin Browser to be installed.</span>
|
||||
|
||||
<span class="ml-1 text-yellow font-bold">Run:</span>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>npm install playwright@latest</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>npx playwright install</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -53,7 +53,7 @@ abstract class AbstractPreset // @pest-arch-ignore-line
|
||||
/**
|
||||
* Runs the given callback for each namespace.
|
||||
*
|
||||
* @param callable(Expectation<string|null>): ArchExpectation ...$callbacks
|
||||
* @param callable(Expectation<string>): ArchExpectation ...$callbacks
|
||||
*/
|
||||
final public function eachUserNamespace(callable ...$callbacks): void
|
||||
{
|
||||
|
||||
@ -35,7 +35,8 @@ final class Laravel extends AbstractPreset
|
||||
->ignoring('App\Features\Concerns');
|
||||
|
||||
$this->expectations[] = expect('App\Features')
|
||||
->toHaveMethod('resolve');
|
||||
->toHaveMethod('resolve')
|
||||
->ignoring('App\Features\Concerns');
|
||||
|
||||
$this->expectations[] = expect('App\Exceptions')
|
||||
->classes()
|
||||
@ -68,6 +69,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveSuffix('Request');
|
||||
|
||||
$this->expectations[] = expect('App\Http\Requests')
|
||||
->classes()
|
||||
->toExtend('Illuminate\Foundation\Http\FormRequest');
|
||||
|
||||
$this->expectations[] = expect('App\Http\Requests')
|
||||
@ -117,6 +119,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveMethod('handle');
|
||||
|
||||
$this->expectations[] = expect('App\Notifications')
|
||||
->classes()
|
||||
->toExtend('Illuminate\Notifications\Notification');
|
||||
|
||||
$this->expectations[] = expect('App')
|
||||
@ -127,6 +130,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveSuffix('ServiceProvider');
|
||||
|
||||
$this->expectations[] = expect('App\Providers')
|
||||
->classes()
|
||||
->toExtend('Illuminate\Support\ServiceProvider');
|
||||
|
||||
$this->expectations[] = expect('App\Providers')
|
||||
@ -149,7 +153,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveSuffix('Controller');
|
||||
|
||||
$this->expectations[] = expect('App\Http')
|
||||
->toOnlyBeUsedIn('App\Http');
|
||||
->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
|
||||
|
||||
$this->expectations[] = expect('App\Http\Controllers')
|
||||
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
|
||||
@ -166,5 +170,11 @@ final class Laravel extends AbstractPreset
|
||||
$this->expectations[] = expect('App\Policies')
|
||||
->classes()
|
||||
->toHaveSuffix('Policy');
|
||||
|
||||
$this->expectations[] = expect('App\Attributes')
|
||||
->classes()
|
||||
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
|
||||
->toHaveAttribute('Attribute')
|
||||
->toHaveMethod('resolve');
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,6 @@ final class Security extends AbstractPreset
|
||||
'create_function',
|
||||
'unserialize',
|
||||
'extract',
|
||||
'parse_str',
|
||||
'mb_parse_str',
|
||||
'dl',
|
||||
'assert',
|
||||
|
||||
@ -17,7 +17,7 @@ final class BootExcludeList implements Bootstrapper
|
||||
*
|
||||
* @var array<int, non-empty-string>
|
||||
*/
|
||||
private const EXCLUDE_LIST = [
|
||||
private const array EXCLUDE_LIST = [
|
||||
'bin',
|
||||
'overrides',
|
||||
'resources',
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Bootstrappers;
|
||||
|
||||
use Pest\Contracts\Bootstrapper;
|
||||
use Pest\Exceptions\FatalException;
|
||||
use Pest\Support\DatasetInfo;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
@ -24,7 +25,7 @@ final class BootFiles implements Bootstrapper
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const STRUCTURE = [
|
||||
private const array STRUCTURE = [
|
||||
'Expectations',
|
||||
'Expectations.php',
|
||||
'Helpers',
|
||||
@ -40,6 +41,10 @@ final class BootFiles implements Bootstrapper
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory();
|
||||
|
||||
if (! is_dir($testsPath)) {
|
||||
throw new FatalException(sprintf('The test directory [%s] does not exist.', $testsPath));
|
||||
}
|
||||
|
||||
foreach (self::STRUCTURE as $filename) {
|
||||
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
|
||||
|
||||
@ -78,7 +83,7 @@ final class BootFiles implements Bootstrapper
|
||||
|
||||
private function bootDatasets(string $testsPath): void
|
||||
{
|
||||
assert(strlen($testsPath) > 0);
|
||||
assert($testsPath !== '');
|
||||
|
||||
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');
|
||||
|
||||
|
||||
@ -15,17 +15,18 @@ final class BootOverrides implements Bootstrapper
|
||||
/**
|
||||
* The list of files to be overridden.
|
||||
*
|
||||
* @var array<string, string>
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const FILES = [
|
||||
'53c246e5f416a39817ac81124cdd64ea8403038d01d7a202e1ffa486fbdf3fa7' => 'Runner/Filter/NameFilterIterator.php',
|
||||
'a4a43de01f641c6944ee83d963795a46d32b5206b5ab3bbc6cce76e67190acbf' => 'Runner/ResultCache/DefaultResultCache.php',
|
||||
'd0e81317889ad88c707db4b08a94cadee4c9010d05ff0a759f04e71af5efed89' => 'Runner/TestSuiteLoader.php',
|
||||
'3bb609b0d3bf6dee8df8d6cd62a3c8ece823c4bb941eaaae39e3cb267171b9d2' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
||||
'8abdad6413329c6fe0d7d44a8b9926e390af32c0b3123f3720bb9c5bbc6fbb7e' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
||||
'b4250fc3ffad5954624cb5e682fd940b874e8d3422fa1ee298bd7225e1aa5fc2' => 'TextUI/TestSuiteFilterProcessor.php',
|
||||
'357d5cd7007f8559b26e1b8cdf43bb6fb15b51b79db981779da6f31b7ec39dad' => 'Event/Value/ThrowableBuilder.php',
|
||||
'ede161507d4c9c27805f55a05a32c3bb528e53b6e1fc092bfafdb8207e0019e9' => 'Logging/JUnit/JunitXmlLogger.php',
|
||||
public const array FILES = [
|
||||
'Runner/Filter/NameFilterIterator.php',
|
||||
'Runner/ResultCache/DefaultResultCache.php',
|
||||
'Runner/TestSuiteLoader.php',
|
||||
'Runner/TestSuiteSorter.php',
|
||||
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
||||
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
||||
'TextUI/TestSuiteFilterProcessor.php',
|
||||
'Event/Value/ThrowableBuilder.php',
|
||||
'Logging/JUnit/JunitXmlLogger.php',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -20,11 +20,21 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
*
|
||||
* @var array<int, class-string<Subscriber>>
|
||||
*/
|
||||
private const SUBSCRIBERS = [
|
||||
private const array SUBSCRIBERS = [
|
||||
Subscribers\EnsureConfigurationIsAvailable::class,
|
||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
||||
Subscribers\EnsureTiaCoverageIsFlushed::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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -8,6 +8,8 @@ use Closure;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @template T of object
|
||||
*/
|
||||
trait Extendable
|
||||
{
|
||||
@ -20,6 +22,8 @@ trait Extendable
|
||||
|
||||
/**
|
||||
* Register a new extend.
|
||||
*
|
||||
* @param-closure-this T $extend
|
||||
*/
|
||||
public function extend(string $name, Closure $extend): void
|
||||
{
|
||||
|
||||
@ -66,6 +66,6 @@ trait Pipeable
|
||||
*/
|
||||
private function pipes(string $name, object $context, string $scope): array
|
||||
{
|
||||
return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
|
||||
return array_map(fn (Closure $pipe): Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,19 @@ namespace Pest\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\ChainableClosure;
|
||||
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 ReflectionException;
|
||||
use ReflectionFunction;
|
||||
@ -71,6 +78,12 @@ trait Testable
|
||||
*/
|
||||
public bool $__ran = false;
|
||||
|
||||
/**
|
||||
* Set when a `BeforeEachable` plugin returns a cached success result.
|
||||
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
|
||||
*/
|
||||
private bool $__cachedPass = false;
|
||||
|
||||
/**
|
||||
* The test's test closure.
|
||||
*/
|
||||
@ -101,27 +114,6 @@ trait Testable
|
||||
*/
|
||||
private array $__snapshotChanges = [];
|
||||
|
||||
/**
|
||||
* Creates a new Test Case instance.
|
||||
*/
|
||||
public function __construct(string $name)
|
||||
{
|
||||
parent::__construct($name);
|
||||
|
||||
$test = TestSuite::getInstance()->tests->get(self::$__filename);
|
||||
|
||||
if ($test->hasMethod($name)) {
|
||||
$method = $test->getMethod($name);
|
||||
$this->__description = self::$__latestDescription = $method->description;
|
||||
self::$__latestAssignees = $method->assignees;
|
||||
self::$__latestNotes = $method->notes;
|
||||
self::$__latestIssues = $method->issues;
|
||||
self::$__latestPrs = $method->prs;
|
||||
$this->__describing = $method->describing;
|
||||
$this->__test = $method->getClosure();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the test case static properties.
|
||||
*/
|
||||
@ -148,7 +140,7 @@ trait Testable
|
||||
*/
|
||||
public function __addBeforeAll(?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof \Closure) {
|
||||
if (! $hook instanceof Closure) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -162,7 +154,7 @@ trait Testable
|
||||
*/
|
||||
public function __addAfterAll(?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof \Closure) {
|
||||
if (! $hook instanceof Closure) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -192,7 +184,7 @@ trait Testable
|
||||
*/
|
||||
private function __addHook(string $property, ?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof \Closure) {
|
||||
if (! $hook instanceof Closure) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -214,7 +206,11 @@ trait Testable
|
||||
$beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll);
|
||||
}
|
||||
|
||||
call_user_func(Closure::bind($beforeAll, null, self::class));
|
||||
try {
|
||||
call_user_func(Closure::bind($beforeAll, null, self::class));
|
||||
} catch (Throwable $e) {
|
||||
Panic::with($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -240,9 +236,35 @@ trait Testable
|
||||
{
|
||||
TestSuite::getInstance()->test = $this;
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
$this->__cachedPass = false;
|
||||
|
||||
$method->setUp($this);
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
|
||||
|
||||
if ($cached !== null) {
|
||||
if ($cached->isSuccess()) {
|
||||
$this->__cachedPass = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-success: throw the matching PHPUnit exception. Runner
|
||||
// catches it and marks the test with the correct status so
|
||||
// skips, failures, incompletes and todos appear in output
|
||||
// exactly as they did in the cached run.
|
||||
if ($cached->isSkipped()) {
|
||||
$this->markTestSkipped($cached->message());
|
||||
}
|
||||
|
||||
if ($cached->isIncomplete()) {
|
||||
$this->markTestIncomplete($cached->message());
|
||||
}
|
||||
|
||||
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
|
||||
}
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
$description = $method->description;
|
||||
if ($this->dataName()) {
|
||||
@ -285,11 +307,44 @@ trait Testable
|
||||
$this->__callClosure($beforeEach, $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test case properties from TestSuite.
|
||||
*/
|
||||
public function __initializeTestCase(): void
|
||||
{
|
||||
// Return if the test case has already been initialized
|
||||
if (isset($this->__test)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->name();
|
||||
$test = TestSuite::getInstance()->tests->get(self::$__filename);
|
||||
|
||||
if ($test->hasMethod($name)) {
|
||||
$method = $test->getMethod($name);
|
||||
$this->__description = self::$__latestDescription = $method->description;
|
||||
self::$__latestAssignees = $method->assignees;
|
||||
self::$__latestNotes = $method->notes;
|
||||
self::$__latestIssues = $method->issues;
|
||||
self::$__latestPrs = $method->prs;
|
||||
$this->__describing = $method->describing;
|
||||
$this->__test = $method->getClosure();
|
||||
|
||||
$method->setUp($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets executed after the Test Case.
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||
|
||||
if ($this->__afterEach instanceof Closure) {
|
||||
@ -315,10 +370,89 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
$this->addToAssertionCount(1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments = $this->__resolveTestArguments($args);
|
||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||
|
||||
return $this->__callClosure($closure, $arguments);
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
if ($method->flakyTries === null) {
|
||||
return $this->__callClosure($closure, $arguments);
|
||||
}
|
||||
|
||||
$lastException = null;
|
||||
$initialProperties = get_object_vars($this);
|
||||
|
||||
for ($attempt = 1; $attempt <= $method->flakyTries; $attempt++) {
|
||||
try {
|
||||
return $this->__callClosure($closure, $arguments);
|
||||
} catch (Throwable $e) {
|
||||
if ($e instanceof SkippedTest
|
||||
|| $e instanceof IncompleteTest
|
||||
|| $this->__isExpectedException($e)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$lastException = $e;
|
||||
|
||||
if ($attempt < $method->flakyTries) {
|
||||
if ($this->__snapshotChanges !== []) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->tearDown();
|
||||
|
||||
Closure::bind(fn (): array => $this->mockObjects = [], $this, TestCase::class)();
|
||||
|
||||
foreach (array_keys(array_diff_key(get_object_vars($this), $initialProperties)) as $property) {
|
||||
unset($this->{$property});
|
||||
}
|
||||
|
||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
||||
|
||||
if ($hasOutputExpectation) {
|
||||
ob_clean();
|
||||
}
|
||||
|
||||
$this->setUp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given exception matches PHPUnit's expected exception.
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
||||
|
||||
$expectedClass = $read('expectedException');
|
||||
|
||||
if ($expectedClass !== null) {
|
||||
return $e instanceof $expectedClass;
|
||||
}
|
||||
|
||||
$expectedMessage = $read('expectedExceptionMessage');
|
||||
|
||||
if ($expectedMessage !== null) {
|
||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedExceptionCode');
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
return $e->getCode() === $expectedCode;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -340,7 +474,8 @@ trait Testable
|
||||
}
|
||||
|
||||
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
|
||||
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest));
|
||||
$testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
|
||||
$testParameterTypes = array_values($testParameterTypesByName);
|
||||
|
||||
if (count($arguments) !== 1) {
|
||||
foreach ($arguments as $argumentIndex => $argumentValue) {
|
||||
@ -348,7 +483,11 @@ trait Testable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) {
|
||||
$parameterType = is_string($argumentIndex)
|
||||
? $testParameterTypesByName[$argumentIndex]
|
||||
: $testParameterTypes[$argumentIndex];
|
||||
|
||||
if (in_array($parameterType, [Closure::class, 'callable', 'mixed'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -374,7 +513,7 @@ trait Testable
|
||||
return [$boundDatasetResult];
|
||||
}
|
||||
|
||||
return array_values($boundDatasetResult);
|
||||
return $boundDatasetResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -434,15 +573,7 @@ trait Testable
|
||||
return;
|
||||
}
|
||||
|
||||
if (count($this->__snapshotChanges) === 1) {
|
||||
$this->markTestIncomplete($this->__snapshotChanges[0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges));
|
||||
|
||||
$this->markTestIncomplete($messages);
|
||||
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -466,7 +597,7 @@ trait Testable
|
||||
*/
|
||||
public static function getLatestPrintableTestCaseMethodName(): string
|
||||
{
|
||||
return self::$__latestDescription;
|
||||
return self::$__latestDescription ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -481,4 +612,12 @@ trait Testable
|
||||
'notes' => self::$__latestNotes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a shell for the test case.
|
||||
*/
|
||||
public function shell(): void
|
||||
{
|
||||
Shell::open();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Pest\PendingCalls\BeforeEachCall;
|
||||
use Pest\PendingCalls\UsesCall;
|
||||
|
||||
/**
|
||||
@ -62,6 +63,14 @@ final readonly class Configuration
|
||||
return (new UsesCall($this->filename, []))->group(...$groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all tests in the current file to be run exclusively.
|
||||
*/
|
||||
public function only(): void
|
||||
{
|
||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on where is called, it will extend the given classes and traits globally or locally.
|
||||
*/
|
||||
@ -102,6 +111,22 @@ final readonly class Configuration
|
||||
return Configuration\Project::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the browser configuration.
|
||||
*/
|
||||
public function browser(): Browser\Configuration
|
||||
{
|
||||
return new Browser\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TIA (Test Impact Analysis) configuration.
|
||||
*/
|
||||
public function tia(): Plugins\Tia\Configuration
|
||||
{
|
||||
return new Plugins\Tia\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies calls to the uses method.
|
||||
*
|
||||
|
||||
@ -16,7 +16,7 @@ final readonly class Help
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const HELP_MESSAGES = [
|
||||
private const array HELP_MESSAGES = [
|
||||
'<comment>Pest Options:</comment>',
|
||||
' <info>--init</info> Initialise a standard Pest configuration',
|
||||
' <info>--coverage</info> Enable coverage and output to standard output',
|
||||
|
||||
@ -22,10 +22,14 @@ final readonly class Thanks
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const FUNDING_MESSAGES = [
|
||||
private const array FUNDING_MESSAGES = [
|
||||
'Star' => 'https://github.com/pestphp/pest',
|
||||
'News' => 'https://twitter.com/pestphp',
|
||||
'Videos' => 'https://youtube.com/@nunomaduro',
|
||||
'YouTube' => 'https://youtube.com/@nunomaduro',
|
||||
'TikTok' => 'https://tiktok.com/@enunomaduro',
|
||||
'Twitch' => 'https://twitch.tv/nunomaduro',
|
||||
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
|
||||
'Instagram' => 'https://instagram.com/enunomaduro',
|
||||
'X' => 'https://x.com/enunomaduro',
|
||||
'Sponsor' => 'https://github.com/sponsors/nunomaduro',
|
||||
];
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ final class DatasetMissing extends BadFunctionCallException implements Exception
|
||||
public function __construct(string $file, string $name, array $arguments)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
"A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s",
|
||||
'A test with the description [%s] has [%d] argument(s) ([%s]) and no dataset(s) provided in [%s]',
|
||||
$name,
|
||||
count($arguments),
|
||||
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),
|
||||
|
||||
@ -20,7 +20,7 @@ final class ShouldNotHappen extends RuntimeException
|
||||
$message = $exception->getMessage();
|
||||
|
||||
parent::__construct(sprintf(<<<'EOF'
|
||||
This should not happen - please create an new issue here: https://github.com/pestphp/pest.
|
||||
This should not happen - please create an new issue here: https://github.com/pestphp/pest/issues
|
||||
|
||||
Issue: %s
|
||||
PHP version: %s
|
||||
|
||||
@ -19,7 +19,11 @@ final class TestCaseAlreadyInUse extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $inUse, string $newOne, string $folder)
|
||||
{
|
||||
parent::__construct(sprintf('Test case `%s` can not be used. The folder `%s` already uses the test case `%s`',
|
||||
$newOne, $folder, $inUse));
|
||||
parent::__construct(sprintf(
|
||||
'Test case [%s] can not be used. The folder [%s] already uses the test case [%s].',
|
||||
$newOne,
|
||||
$folder,
|
||||
$inUse,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ final class TestClosureMustNotBeStatic extends InvalidArgumentException implemen
|
||||
{
|
||||
parent::__construct(
|
||||
sprintf(
|
||||
'Test closure must not be static. Please remove the `static` keyword from the `%s` method in `%s`.',
|
||||
'Test closure must not be static. Please remove the [static] keyword from the [%s] method in [%s].',
|
||||
$method->description,
|
||||
$method->filename
|
||||
)
|
||||
|
||||
@ -18,6 +18,7 @@ use Pest\Arch\Expectations\ToOnlyUse;
|
||||
use Pest\Arch\Expectations\ToUse;
|
||||
use Pest\Arch\Expectations\ToUseNothing;
|
||||
use Pest\Arch\PendingArchExpectation;
|
||||
use Pest\Arch\Support\Composer;
|
||||
use Pest\Arch\Support\FileLineFinder;
|
||||
use Pest\Concerns\Extendable;
|
||||
use Pest\Concerns\Pipeable;
|
||||
@ -52,7 +53,9 @@ use ReflectionProperty;
|
||||
*/
|
||||
final class Expectation
|
||||
{
|
||||
/** @use Extendable<self<TValue>> */
|
||||
use Extendable;
|
||||
|
||||
use Pipeable;
|
||||
use Retrievable;
|
||||
|
||||
@ -134,7 +137,7 @@ final class Expectation
|
||||
/**
|
||||
* Dump the expectation value when the result of the condition is truthy.
|
||||
*
|
||||
* @param (\Closure(TValue): bool)|bool $condition
|
||||
* @param (Closure(TValue): bool)|bool $condition
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
|
||||
@ -151,7 +154,7 @@ final class Expectation
|
||||
/**
|
||||
* Dump the expectation value when the result of the condition is falsy.
|
||||
*
|
||||
* @param (\Closure(TValue): bool)|bool $condition
|
||||
* @param (Closure(TValue): bool)|bool $condition
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
|
||||
@ -330,7 +333,7 @@ final class Expectation
|
||||
* @param array<int, mixed> $parameters
|
||||
* @return Expectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue>
|
||||
*/
|
||||
public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation
|
||||
public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation|ArchExpectation
|
||||
{
|
||||
if (! self::hasMethod($method)) {
|
||||
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) {
|
||||
@ -355,6 +358,10 @@ final class Expectation
|
||||
$reflectionClosure = new \ReflectionFunction($closure);
|
||||
$expectation = $reflectionClosure->getClosureThis();
|
||||
|
||||
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
|
||||
return $closure(...$parameters);
|
||||
}
|
||||
|
||||
assert(is_object($expectation));
|
||||
|
||||
ExpectationPipeline::for($closure)
|
||||
@ -393,7 +400,7 @@ final class Expectation
|
||||
*
|
||||
* @return Expectation<TValue>|OppositeExpectation<TValue>|EachExpectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue|null>|TValue
|
||||
*/
|
||||
public function __get(string $name)
|
||||
public function __get(string $name): mixed
|
||||
{
|
||||
if (! self::hasMethod($name)) {
|
||||
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) {
|
||||
@ -535,7 +542,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isFinal(),
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isFinal(),
|
||||
'to be final',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -548,7 +555,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
|
||||
'to be readonly',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -561,7 +568,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isTrait(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isTrait(),
|
||||
'to be trait',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -582,7 +589,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isAbstract(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isAbstract(),
|
||||
'to be abstract',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -599,7 +606,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => $object->reflectionClass->hasMethod($method))) === count($methods),
|
||||
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod($method))) === count($methods),
|
||||
sprintf("to have method '%s'", implode("', '", $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -663,6 +670,41 @@ final class Expectation
|
||||
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is cased correctly.
|
||||
*/
|
||||
public function toBeCasedCorrectly(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object): bool {
|
||||
if (! isset($object->reflectionClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$realPath = realpath($object->path);
|
||||
|
||||
if ($realPath === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (Composer::allNamespacesWithDirectories() as $directory => $namespace) {
|
||||
if (str_starts_with($realPath, $directory)) {
|
||||
$relativePath = substr($realPath, strlen($directory) + 1);
|
||||
$relativePath = explode('.', $relativePath)[0];
|
||||
$classFromPath = $namespace.'\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
|
||||
|
||||
return $classFromPath === $object->reflectionClass->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
'to be cased correctly',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is enum.
|
||||
*/
|
||||
@ -670,7 +712,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isEnum(),
|
||||
'to be enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -712,7 +754,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isInterface(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isInterface(),
|
||||
'to be interface',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -733,7 +775,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && ($class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class)),
|
||||
sprintf("to extend '%s'", $class),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -773,7 +815,26 @@ final class Expectation
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($traits): bool {
|
||||
foreach ($traits as $trait) {
|
||||
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
if (isset($object->reflectionClass) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentClass = $object->reflectionClass;
|
||||
$usedTraits = [];
|
||||
|
||||
do {
|
||||
$classTraits = $currentClass->getTraits();
|
||||
foreach ($classTraits as $traitReflection) {
|
||||
$usedTraits[$traitReflection->getName()] = $traitReflection->getName();
|
||||
|
||||
$nestedTraits = $traitReflection->getTraits();
|
||||
foreach ($nestedTraits as $nestedTrait) {
|
||||
$usedTraits[$nestedTrait->getName()] = $nestedTrait->getName();
|
||||
}
|
||||
}
|
||||
} while ($currentClass = $currentClass->getParentClass());
|
||||
|
||||
if (! array_key_exists($trait, $usedTraits)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -792,7 +853,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() === [],
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getInterfaceNames() === [],
|
||||
'to implement nothing',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -809,7 +870,8 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => count($interfaces) === count($object->reflectionClass->getInterfaceNames())
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||
&& (count($interfaces) === count($object->reflectionClass->getInterfaceNames()))
|
||||
&& array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [],
|
||||
"to only implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
@ -823,7 +885,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
"to have prefix '{$prefix}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -836,7 +898,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
"to have suffix '{$suffix}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -855,7 +917,7 @@ final class Expectation
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (! $object->reflectionClass->implementsInterface($interface)) {
|
||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -885,6 +947,14 @@ final class Expectation
|
||||
return ToUseNothing::make($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the source code of the given expectation target does not include suspicious characters.
|
||||
*/
|
||||
public function toHaveSuspiciousCharacters(): ArchExpectation
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHaveSuspiciousCharacters']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
@ -928,7 +998,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->hasMethod('__invoke'),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod('__invoke'),
|
||||
'to be invokable',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
@ -1037,7 +1107,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) !== [],
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [],
|
||||
"to have attribute '{$attribute}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -1066,7 +1136,8 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum()
|
||||
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
|
||||
'to be '.$backingType.' backed enum',
|
||||
|
||||
@ -15,6 +15,7 @@ use Pest\Arch\PendingArchExpectation;
|
||||
use Pest\Arch\SingleArchExpectation;
|
||||
use Pest\Arch\Support\FileLineFinder;
|
||||
use Pest\Exceptions\InvalidExpectation;
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Pest\Expectation;
|
||||
use Pest\Support\Arr;
|
||||
use Pest\Support\Exporter;
|
||||
@ -24,6 +25,7 @@ use PHPUnit\Framework\AssertionFailedError;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
use ReflectionMethod;
|
||||
use ReflectionProperty;
|
||||
use Spoofchecker;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
@ -74,7 +76,10 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toUse(array|string $targets): ArchExpectation
|
||||
{
|
||||
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($this->original, $target)->opposite(
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($original, $target)->opposite(
|
||||
fn () => $this->throwExpectationFailedException('toUse', $target),
|
||||
), is_string($targets) ? [$targets] : $targets));
|
||||
}
|
||||
@ -84,8 +89,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) !== $permissions,
|
||||
sprintf('permissions not to be [%s]', $permissions),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
|
||||
@ -105,8 +113,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveMethodsDocumented(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| array_filter(
|
||||
Reflection::getMethodsFromReflectionClass($object->reflectionClass),
|
||||
@ -124,8 +135,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHavePropertiesDocumented(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| array_filter(
|
||||
Reflection::getPropertiesFromReflectionClass($object->reflectionClass),
|
||||
@ -144,8 +158,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toUseStrictTypes(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! (bool) preg_match('/^<\?php\s+declare\(.*?strict_types\s?=\s?1.*?\);/', (string) file_get_contents($object->path)),
|
||||
'not to use strict types',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
|
||||
@ -157,8 +174,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toUseStrictEquality(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! str_contains((string) file_get_contents($object->path), ' === ') && ! str_contains((string) file_get_contents($object->path), ' !== '),
|
||||
'to use strict equality',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' === ') || str_contains($line, ' !== ')),
|
||||
@ -170,9 +190,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeFinal(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isFinal(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isFinal()),
|
||||
'not to be final',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -183,9 +206,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeReadonly(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isReadOnly()) && assert(true), // @phpstan-ignore-line
|
||||
'not to be readonly',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -196,9 +222,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeTrait(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isTrait(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isTrait(),
|
||||
'not to be trait',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -217,9 +246,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeAbstract(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isAbstract(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isAbstract(),
|
||||
'not to be abstract',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -234,17 +266,42 @@ final readonly class OppositeExpectation
|
||||
{
|
||||
$methods = is_array($method) ? $method : [$method];
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => array_filter(
|
||||
$methods,
|
||||
fn (string $method): bool => $object->reflectionClass->hasMethod($method),
|
||||
fn (string $method): bool => isset($object->reflectionClass) === false || $object->reflectionClass->hasMethod($method),
|
||||
) === [],
|
||||
'to not have methods: '.implode(', ', $methods),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have suspicious characters.
|
||||
*/
|
||||
public function toHaveSuspiciousCharacters(): ArchExpectation
|
||||
{
|
||||
if (! class_exists(Spoofchecker::class)) {
|
||||
throw new MissingDependency(__FUNCTION__, 'ext-intl >= 2.0');
|
||||
}
|
||||
|
||||
$checker = new Spoofchecker;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! $checker->isSuspicious((string) file_get_contents($object->path)),
|
||||
'to not include suspicious characters',
|
||||
FileLineFinder::where(fn (string $line): bool => $checker->isSuspicious($line)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have the given methods.
|
||||
*
|
||||
@ -266,8 +323,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($methods, &$state): bool {
|
||||
$reflectionMethods = isset($object->reflectionClass)
|
||||
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC)
|
||||
@ -286,7 +346,7 @@ final readonly class OppositeExpectation
|
||||
$methods === []
|
||||
? 'not to have public methods'
|
||||
: sprintf("not to have public methods besides '%s'", implode("', '", $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, $state->contains)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -309,8 +369,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($methods, &$state): bool {
|
||||
$reflectionMethods = isset($object->reflectionClass)
|
||||
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED)
|
||||
@ -329,7 +392,7 @@ final readonly class OppositeExpectation
|
||||
$methods === []
|
||||
? 'not to have protected methods'
|
||||
: sprintf("not to have protected methods besides '%s'", implode("', '", $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, $state->contains)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -352,8 +415,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($methods, &$state): bool {
|
||||
$reflectionMethods = isset($object->reflectionClass)
|
||||
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE)
|
||||
@ -372,7 +438,7 @@ final readonly class OppositeExpectation
|
||||
$methods === []
|
||||
? 'not to have private methods'
|
||||
: sprintf("not to have private methods besides '%s'", implode("', '", $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, $state->contains)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -389,9 +455,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeEnum(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isEnum(),
|
||||
'not to be enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -410,8 +479,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeClass(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! class_exists($object->name),
|
||||
'not to be class',
|
||||
FileLineFinder::where(fn (string $line): bool => true),
|
||||
@ -431,9 +503,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeInterface(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isInterface(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isInterface(),
|
||||
'not to be interface',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -452,9 +527,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toExtend(string $class): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isSubclassOf($class),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isSubclassOf($class),
|
||||
sprintf("not to extend '%s'", $class),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -465,9 +543,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toExtendNothing(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() !== false,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getParentClass() !== false,
|
||||
'to extend a class',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -490,11 +571,14 @@ final readonly class OppositeExpectation
|
||||
{
|
||||
$traits = is_array($traits) ? $traits : [$traits];
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($traits): bool {
|
||||
foreach ($traits as $trait) {
|
||||
if (in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -515,11 +599,14 @@ final readonly class OppositeExpectation
|
||||
{
|
||||
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if ($object->reflectionClass->implementsInterface($interface)) {
|
||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -536,9 +623,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toImplementNothing(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() !== [],
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getInterfaceNames() !== [],
|
||||
'to implement an interface',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -557,9 +647,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHavePrefix(string $prefix): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
"not to have prefix '{$prefix}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -570,9 +663,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveSuffix(string $suffix): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
"not to have suffix '{$suffix}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@ -599,7 +695,10 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeUsed(): ArchExpectation
|
||||
{
|
||||
return ToBeUsedInNothing::make($this->original);
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return ToBeUsedInNothing::make($original);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -609,7 +708,10 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeUsedIn(array|string $targets): ArchExpectation
|
||||
{
|
||||
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($this->original, $target)->opposite(
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($original, $target)->opposite(
|
||||
fn () => $this->throwExpectationFailedException('toBeUsedIn', $target),
|
||||
), is_string($targets) ? [$targets] : $targets));
|
||||
}
|
||||
@ -632,9 +734,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeInvokable(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->hasMethod('__invoke'),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->hasMethod('__invoke'),
|
||||
'to not be invokable',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
@ -645,9 +750,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveAttribute(string $attribute): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) === [],
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getAttributes($attribute) === [],
|
||||
"to not have attribute '{$attribute}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
@ -737,9 +845,13 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
private function toBeBackedEnum(string $backingType): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum()
|
||||
$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
|
||||
'not to be '.$backingType.' backed enum',
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Factories\Covers;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoversNothing {}
|
||||
@ -17,6 +17,7 @@ use Pest\Factories\Concerns\HigherOrderable;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
@ -58,6 +59,11 @@ final class TestCaseFactory
|
||||
Concerns\Expectable::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The namespace for the test case, overrides the path-based namespace when set.
|
||||
*/
|
||||
public ?string $namespace = null;
|
||||
|
||||
/**
|
||||
* Creates a new Factory instance.
|
||||
*/
|
||||
@ -110,8 +116,8 @@ final class TestCaseFactory
|
||||
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
|
||||
// Remove escaped quote sequences (maintain namespace)
|
||||
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
|
||||
// Limit to A-Z, a-z, 0-9, '_', '-'.
|
||||
$relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath);
|
||||
// Limit to Unicode letters and numbers.
|
||||
$relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath);
|
||||
|
||||
$classFQN = 'P\\'.$relativePath;
|
||||
|
||||
@ -126,7 +132,7 @@ final class TestCaseFactory
|
||||
|
||||
$partsFQN = explode('\\', $classFQN);
|
||||
$className = array_pop($partsFQN);
|
||||
$namespace = implode('\\', $partsFQN);
|
||||
$namespace = $this->namespace ?? implode('\\', $partsFQN);
|
||||
$baseClass = sprintf('\%s', $this->class);
|
||||
|
||||
if (trim($className) === '') {
|
||||
@ -135,7 +141,7 @@ final class TestCaseFactory
|
||||
|
||||
$this->attributes = [
|
||||
new Attribute(
|
||||
\PHPUnit\Framework\Attributes\TestDox::class,
|
||||
TestDox::class,
|
||||
[$this->filename],
|
||||
),
|
||||
...$this->attributes,
|
||||
|
||||
@ -9,10 +9,14 @@ use Pest\Evaluators\Attributes;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Factories\Concerns\HigherOrderable;
|
||||
use Pest\Repositories\DatasetsRepository;
|
||||
use Pest\Support\Description;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Depends;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
@ -32,7 +36,7 @@ final class TestCaseMethodFactory
|
||||
/**
|
||||
* The test's describing, if any.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
public array $describing = [];
|
||||
|
||||
@ -46,6 +50,11 @@ final class TestCaseMethodFactory
|
||||
*/
|
||||
public int $repetitions = 1;
|
||||
|
||||
/**
|
||||
* The test's number of flaky retry tries.
|
||||
*/
|
||||
public ?int $flakyTries = null;
|
||||
|
||||
/**
|
||||
* Determines if the test is a "todo".
|
||||
*/
|
||||
@ -155,7 +164,7 @@ final class TestCaseMethodFactory
|
||||
assert($testCase instanceof TestCaseFactory);
|
||||
$method = $this;
|
||||
|
||||
return function (...$arguments) use ($testCase, $method, $closure): mixed { // @phpstan-ignore-line
|
||||
return function (...$arguments) use ($testCase, $method, $closure): mixed {
|
||||
/* @var TestCase $this */
|
||||
$testCase->proxies->proxy($this);
|
||||
$method->proxies->proxy($this);
|
||||
@ -192,11 +201,11 @@ final class TestCaseMethodFactory
|
||||
|
||||
$this->attributes = [
|
||||
new Attribute(
|
||||
\PHPUnit\Framework\Attributes\Test::class,
|
||||
Test::class,
|
||||
[],
|
||||
),
|
||||
new Attribute(
|
||||
\PHPUnit\Framework\Attributes\TestDox::class,
|
||||
TestDox::class,
|
||||
[str_replace('*/', '{@*}', $this->description)],
|
||||
),
|
||||
...$this->attributes,
|
||||
@ -206,7 +215,7 @@ final class TestCaseMethodFactory
|
||||
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
|
||||
|
||||
$this->attributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\Depends::class,
|
||||
Depends::class,
|
||||
[$depend],
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Concerns\Expectable;
|
||||
use Pest\Browser\Api\ArrayablePendingAwaitablePage;
|
||||
use Pest\Browser\Api\PendingAwaitablePage;
|
||||
use Pest\Configuration;
|
||||
use Pest\Exceptions\AfterAllWithinDescribe;
|
||||
use Pest\Exceptions\BeforeAllWithinDescribe;
|
||||
use Pest\Expectation;
|
||||
use Pest\Installers\PluginBrowser;
|
||||
use Pest\Mutate\Contracts\MutationTestRunner;
|
||||
use Pest\Mutate\Repositories\ConfigurationRepository;
|
||||
use Pest\PendingCalls\AfterEachCall;
|
||||
@ -18,6 +20,7 @@ use Pest\Repositories\DatasetsRepository;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\DatasetInfo;
|
||||
use Pest\Support\Description;
|
||||
use Pest\Support\HigherOrderTapProxy;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@ -44,7 +47,7 @@ if (! function_exists('beforeAll')) {
|
||||
function beforeAll(Closure $closure): void
|
||||
{
|
||||
if (DescribeCall::describing() !== []) {
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
throw new BeforeAllWithinDescribe($filename);
|
||||
}
|
||||
@ -57,13 +60,11 @@ if (! function_exists('beforeEach')) {
|
||||
/**
|
||||
* Runs the given closure before each test in the current file.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
*
|
||||
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
|
||||
* @param-closure-this TestCall $closure
|
||||
*/
|
||||
function beforeEach(?Closure $closure = null): BeforeEachCall
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
|
||||
}
|
||||
@ -88,14 +89,12 @@ if (! function_exists('describe')) {
|
||||
* Adds the given closure as a group of tests. The first argument
|
||||
* is the group description; the second argument is a closure
|
||||
* that contains the group tests.
|
||||
*
|
||||
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
|
||||
*/
|
||||
function describe(string $description, Closure $tests): DescribeCall
|
||||
{
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new DescribeCall(TestSuite::getInstance(), $filename, $description, $tests);
|
||||
return new DescribeCall(TestSuite::getInstance(), $filename, new Description($description), $tests);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +107,7 @@ if (! function_exists('uses')) {
|
||||
*/
|
||||
function uses(string ...$classAndTraits): UsesCall
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new UsesCall($filename, array_values($classAndTraits));
|
||||
}
|
||||
@ -120,7 +119,7 @@ if (! function_exists('pest')) {
|
||||
*/
|
||||
function pest(): Configuration
|
||||
{
|
||||
return new Configuration(Backtrace::file());
|
||||
return new Configuration(Backtrace::testFile());
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,13 +129,13 @@ if (! function_exists('test')) {
|
||||
* is the test description; the second argument is
|
||||
* a closure that contains the test expectations.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
* @param-closure-this TestCall $closure
|
||||
*
|
||||
* @return Expectable|TestCall|TestCase|mixed
|
||||
* @return ($description is string ? TestCall : HigherOrderTapProxy|TestCall)
|
||||
*/
|
||||
function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall
|
||||
{
|
||||
if ($description === null && TestSuite::getInstance()->test instanceof \PHPUnit\Framework\TestCase) {
|
||||
if ($description === null && TestSuite::getInstance()->test instanceof TestCase) {
|
||||
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
|
||||
}
|
||||
|
||||
@ -152,34 +151,23 @@ if (! function_exists('it')) {
|
||||
* is the test description; the second argument is
|
||||
* a closure that contains the test expectations.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
*
|
||||
* @return Expectable|TestCall|TestCase|mixed
|
||||
* @param-closure-this TestCall $closure
|
||||
*/
|
||||
function it(string $description, ?Closure $closure = null): TestCall
|
||||
{
|
||||
$description = sprintf('it %s', $description);
|
||||
|
||||
/** @var TestCall $test */
|
||||
$test = test($description, $closure);
|
||||
|
||||
return $test;
|
||||
return test($description, $closure);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('todo')) {
|
||||
/**
|
||||
* Creates a new test that is marked as "todo".
|
||||
*
|
||||
* @return Expectable|TestCall|TestCase|mixed
|
||||
*/
|
||||
function todo(string $description): TestCall
|
||||
{
|
||||
$test = test($description);
|
||||
|
||||
assert($test instanceof TestCall);
|
||||
|
||||
return $test->todo();
|
||||
return test($description)->todo();
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,13 +175,11 @@ if (! function_exists('afterEach')) {
|
||||
/**
|
||||
* Runs the given closure after each test in the current file.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
*
|
||||
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|TestCall|mixed
|
||||
* @param-closure-this TestCall $closure
|
||||
*/
|
||||
function afterEach(?Closure $closure = null): AfterEachCall
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
|
||||
}
|
||||
@ -206,7 +192,7 @@ if (! function_exists('afterAll')) {
|
||||
function afterAll(Closure $closure): void
|
||||
{
|
||||
if (DescribeCall::describing() !== []) {
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
throw new AfterAllWithinDescribe($filename);
|
||||
}
|
||||
@ -223,7 +209,7 @@ if (! function_exists('covers')) {
|
||||
*/
|
||||
function covers(array|string ...$classesOrFunctions): void
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
||||
|
||||
@ -232,7 +218,7 @@ if (! function_exists('covers')) {
|
||||
|
||||
/** @var MutationTestRunner $runner */
|
||||
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
||||
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
|
||||
/** @var ConfigurationRepository $configurationRepository */
|
||||
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
||||
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
||||
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
||||
@ -252,14 +238,14 @@ if (! function_exists('mutates')) {
|
||||
*/
|
||||
function mutates(array|string ...$targets): void
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
||||
$beforeEachCall->group('__pest_mutate_only');
|
||||
|
||||
/** @var MutationTestRunner $runner */
|
||||
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
||||
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
|
||||
/** @var ConfigurationRepository $configurationRepository */
|
||||
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
||||
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
||||
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
||||
@ -278,3 +264,51 @@ if (! function_exists('mutates')) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('fixture')) {
|
||||
/**
|
||||
* Returns the absolute path to a fixture file.
|
||||
*/
|
||||
function fixture(string $file): string
|
||||
{
|
||||
$file = implode(DIRECTORY_SEPARATOR, [
|
||||
TestSuite::getInstance()->rootPath,
|
||||
TestSuite::getInstance()->testPath,
|
||||
'Fixtures',
|
||||
str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file),
|
||||
]);
|
||||
|
||||
$fileRealPath = realpath($file);
|
||||
|
||||
if ($fileRealPath === false) {
|
||||
throw new InvalidArgumentException(
|
||||
'The fixture file ['.$file.'] does not exist.',
|
||||
);
|
||||
}
|
||||
|
||||
return $fileRealPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('visit')) {
|
||||
/**
|
||||
* Browse to the given URL.
|
||||
*
|
||||
* @template TUrl of array<int, string>|string
|
||||
*
|
||||
* @param TUrl $url
|
||||
* @param array<string, mixed> $options
|
||||
* @return (TUrl is array<int, string> ? ArrayablePendingAwaitablePage : PendingAwaitablePage)
|
||||
*/
|
||||
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
|
||||
{
|
||||
if (! class_exists(Pest\Browser\Configuration::class)) {
|
||||
PluginBrowser::install();
|
||||
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
return test()->visit($url, $options);
|
||||
}
|
||||
}
|
||||
|
||||
15
src/Installers/PluginBrowser.php
Normal file
15
src/Installers/PluginBrowser.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Installers;
|
||||
|
||||
use Pest\Support\View;
|
||||
|
||||
final readonly class PluginBrowser
|
||||
{
|
||||
public static function install(): void
|
||||
{
|
||||
View::render('installers/plugin-browser');
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ use Pest\Plugins\Actions\CallsBoot;
|
||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||
use Pest\Plugins\Actions\CallsHandleOriginalArguments;
|
||||
use Pest\Plugins\Actions\CallsTerminable;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\View;
|
||||
@ -34,7 +35,7 @@ final readonly class Kernel
|
||||
*
|
||||
* @var array<int, class-string>
|
||||
*/
|
||||
private const BOOTSTRAPPERS = [
|
||||
private const array BOOTSTRAPPERS = [
|
||||
Bootstrappers\BootOverrides::class,
|
||||
Bootstrappers\BootSubscribers::class,
|
||||
Bootstrappers\BootFiles::class,
|
||||
@ -64,14 +65,17 @@ final readonly class Kernel
|
||||
->add(TestSuite::class, $testSuite)
|
||||
->add(InputInterface::class, $input)
|
||||
->add(OutputInterface::class, $output)
|
||||
->add(Container::class, $container);
|
||||
->add(Container::class, $container)
|
||||
->add(Tia\Recorder::class, new Tia\Recorder)
|
||||
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
|
||||
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
|
||||
|
||||
$kernel = new self(
|
||||
new Application,
|
||||
$output,
|
||||
);
|
||||
|
||||
register_shutdown_function(fn () => $kernel->shutdown());
|
||||
register_shutdown_function($kernel->shutdown(...));
|
||||
|
||||
foreach (self::BOOTSTRAPPERS as $bootstrapper) {
|
||||
$bootstrapper = Container::getInstance()->get($bootstrapper);
|
||||
|
||||
@ -11,6 +11,7 @@ use Pest\Support\Str;
|
||||
use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
@ -30,7 +31,7 @@ final readonly class Converter
|
||||
/**
|
||||
* The prefix for the test suite name.
|
||||
*/
|
||||
private const PREFIX = 'P\\';
|
||||
private const string PREFIX = 'P\\';
|
||||
|
||||
/**
|
||||
* The state generator.
|
||||
@ -130,7 +131,7 @@ final readonly class Converter
|
||||
|
||||
// clean the paths of each frame.
|
||||
$frames = array_map(
|
||||
fn (string $frame): string => $this->toRelativePath($frame),
|
||||
$this->toRelativePath(...),
|
||||
$frames
|
||||
);
|
||||
|
||||
@ -150,7 +151,7 @@ final readonly class Converter
|
||||
{
|
||||
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
|
||||
$firstTest = $this->getFirstTest($testSuite);
|
||||
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
|
||||
if ($firstTest instanceof TestMethod) {
|
||||
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
|
||||
}
|
||||
}
|
||||
@ -178,7 +179,7 @@ final readonly class Converter
|
||||
public function getTestSuiteLocation(TestSuite $testSuite): ?string
|
||||
{
|
||||
$firstTest = $this->getFirstTest($testSuite);
|
||||
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
|
||||
if (! $firstTest instanceof TestMethod) {
|
||||
return null;
|
||||
}
|
||||
$path = $firstTest->testDox()->prettifiedClassName();
|
||||
@ -254,8 +255,9 @@ final readonly class Converter
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored) {
|
||||
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored
|
||||
|| $event instanceof AfterLastTestMethodErrored) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
|
||||
|
||||
@ -200,7 +200,7 @@ final class TeamCityLogger
|
||||
|
||||
public function testFinished(Finished $event): void
|
||||
{
|
||||
if (! $this->time instanceof \PHPUnit\Event\Telemetry\HRTime) {
|
||||
if (! $this->time instanceof HRTime) {
|
||||
throw ShouldNotHappen::fromMessage('Start time has not been set.');
|
||||
}
|
||||
|
||||
@ -232,7 +232,6 @@ final class TeamCityLogger
|
||||
$reflector = new ReflectionClass($telemetry);
|
||||
|
||||
$property = $reflector->getProperty('current');
|
||||
$property->setAccessible(true);
|
||||
$snapshot = $property->getValue($telemetry);
|
||||
assert($snapshot instanceof Snapshot);
|
||||
|
||||
|
||||
@ -9,10 +9,12 @@ use Closure;
|
||||
use Countable;
|
||||
use DateTimeInterface;
|
||||
use Error;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
use Pest\Exceptions\InvalidExpectationValue;
|
||||
use Pest\Matchers\Any;
|
||||
use Pest\Plugins\Snapshot;
|
||||
use Pest\Support\Arr;
|
||||
use Pest\Support\Exporter;
|
||||
use Pest\Support\NullClosure;
|
||||
@ -183,7 +185,6 @@ final class Expectation
|
||||
{
|
||||
foreach ($needles as $needle) {
|
||||
if (is_string($this->value)) {
|
||||
// @phpstan-ignore-next-line
|
||||
Assert::assertStringContainsString((string) $needle, $this->value);
|
||||
} else {
|
||||
if (! is_iterable($this->value)) {
|
||||
@ -782,15 +783,13 @@ final class Expectation
|
||||
foreach ($array as $key => $value) {
|
||||
Assert::assertArrayHasKey($key, $valueAsArray, $message);
|
||||
|
||||
if ($message === '') {
|
||||
$message = sprintf(
|
||||
'Failed asserting that an array has a key %s with the value %s.',
|
||||
$this->export($key),
|
||||
$this->export($valueAsArray[$key]),
|
||||
);
|
||||
}
|
||||
$assertMessage = $message !== '' ? $message : sprintf(
|
||||
'Failed asserting that an array has a key %s with the value %s.',
|
||||
$this->export($key),
|
||||
$this->export($valueAsArray[$key]),
|
||||
);
|
||||
|
||||
Assert::assertEquals($value, $valueAsArray[$key], $message);
|
||||
Assert::assertEquals($value, $valueAsArray[$key], $assertMessage);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@ -803,7 +802,7 @@ final class Expectation
|
||||
* @param iterable<string, mixed> $object
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toMatchObject(iterable $object, string $message = ''): self
|
||||
public function toMatchObject(object|iterable $object, string $message = ''): self
|
||||
{
|
||||
foreach ((array) $object as $property => $value) {
|
||||
if (! is_object($this->value) && ! is_string($this->value)) {
|
||||
@ -815,15 +814,13 @@ final class Expectation
|
||||
/* @phpstan-ignore-next-line */
|
||||
$propertyValue = $this->value->{$property};
|
||||
|
||||
if ($message === '') {
|
||||
$message = sprintf(
|
||||
'Failed asserting that an object has a property %s with the value %s.',
|
||||
$this->export($property),
|
||||
$this->export($propertyValue),
|
||||
);
|
||||
}
|
||||
$assertMessage = $message !== '' ? $message : sprintf(
|
||||
'Failed asserting that an object has a property %s with the value %s.',
|
||||
$this->export($property),
|
||||
$this->export($propertyValue),
|
||||
);
|
||||
|
||||
Assert::assertEquals($value, $propertyValue, $message);
|
||||
Assert::assertEquals($value, $propertyValue, $assertMessage);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@ -847,7 +844,7 @@ final class Expectation
|
||||
is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(),
|
||||
is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(),
|
||||
is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(),
|
||||
$this->value instanceof \Illuminate\Testing\TestResponse => $this->value->getContent(), // @phpstan-ignore-line
|
||||
$this->value instanceof TestResponse => $this->value->getContent(), // @phpstan-ignore-line
|
||||
is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||
$this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||
@ -855,18 +852,31 @@ final class Expectation
|
||||
default => InvalidExpectationValue::expected('array|object|string'),
|
||||
};
|
||||
|
||||
if ($snapshots->has()) {
|
||||
[$filename, $content] = $snapshots->get();
|
||||
|
||||
Assert::assertSame(
|
||||
strtr($content, ["\r\n" => "\n", "\r" => "\n"]),
|
||||
strtr($string, ["\r\n" => "\n", "\r" => "\n"]),
|
||||
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
|
||||
);
|
||||
} else {
|
||||
if (! $snapshots->has()) {
|
||||
$filename = $snapshots->save($string);
|
||||
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
|
||||
} else {
|
||||
[$filename, $content] = $snapshots->get();
|
||||
|
||||
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
|
||||
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
|
||||
|
||||
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
|
||||
$snapshots->save($string);
|
||||
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
|
||||
} else {
|
||||
if (Snapshot::$updateSnapshots) {
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
|
||||
}
|
||||
|
||||
Assert::assertSame(
|
||||
$normalizedContent,
|
||||
$normalizedString,
|
||||
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
@ -988,7 +998,7 @@ final class Expectation
|
||||
*/
|
||||
private function export(mixed $value): string
|
||||
{
|
||||
if (! $this->exporter instanceof \Pest\Support\Exporter) {
|
||||
if (! $this->exporter instanceof Exporter) {
|
||||
$this->exporter = Exporter::default();
|
||||
}
|
||||
|
||||
@ -1159,4 +1169,21 @@ final class Expectation
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value can be converted to a slug
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeSlug(string $message = ''): self
|
||||
{
|
||||
if ($message === '') {
|
||||
$message = "Failed asserting that {$this->value} can be converted to a slug.";
|
||||
}
|
||||
|
||||
$slug = Str::slugify((string) $this->value);
|
||||
Assert::assertNotEmpty($slug, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
57
src/PHPStan/HigherOrderExpectationTypeExtension.php
Normal file
57
src/PHPStan/HigherOrderExpectationTypeExtension.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\PHPStan;
|
||||
|
||||
use Pest\Expectations\HigherOrderExpectation;
|
||||
use PhpParser\Node\Expr;
|
||||
use PhpParser\Node\Expr\PropertyFetch;
|
||||
use PhpParser\Node\Identifier;
|
||||
use PHPStan\Analyser\Scope;
|
||||
use PHPStan\Reflection\ReflectionProvider;
|
||||
use PHPStan\Type\ExpressionTypeResolverExtension;
|
||||
use PHPStan\Type\ObjectType;
|
||||
use PHPStan\Type\Type;
|
||||
|
||||
/**
|
||||
* Prevents native declared properties of HigherOrderExpectation (like $original,
|
||||
* $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.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class HigherOrderExpectationTypeExtension implements ExpressionTypeResolverExtension
|
||||
{
|
||||
public function __construct(
|
||||
private ReflectionProvider $reflectionProvider,
|
||||
) {}
|
||||
|
||||
public function getType(Expr $expr, Scope $scope): ?Type
|
||||
{
|
||||
if (! $expr instanceof PropertyFetch || ! $expr->name instanceof Identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$varType = $scope->getType($expr->var);
|
||||
|
||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->reflectionProvider->hasClass(HigherOrderExpectation::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$propertyName = $expr->name->name;
|
||||
$classReflection = $this->reflectionProvider->getClass(HigherOrderExpectation::class);
|
||||
|
||||
if (! $classReflection->hasNativeProperty($propertyName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $varType->getProperty($propertyName, $scope)->getReadableType();
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,7 @@ final readonly class Panic
|
||||
{
|
||||
try {
|
||||
$output = Container::getInstance()->get(OutputInterface::class);
|
||||
} catch (Throwable) { // @phpstan-ignore-line
|
||||
} catch (Throwable) {
|
||||
$output = new ConsoleOutput;
|
||||
}
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ final class AfterEachCall
|
||||
|
||||
$afterEachTestCase = ChainableClosure::boundWhen(
|
||||
fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
|
||||
ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line
|
||||
ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class),
|
||||
)->bindTo($this, self::class);
|
||||
|
||||
assert($afterEachTestCase instanceof Closure);
|
||||
|
||||
@ -79,7 +79,7 @@ final class BeforeEachCall
|
||||
|
||||
$beforeEachTestCase = ChainableClosure::boundWhen(
|
||||
fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
|
||||
ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line
|
||||
ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class),
|
||||
)->bindTo($this, self::class);
|
||||
|
||||
assert($beforeEachTestCase instanceof Closure);
|
||||
|
||||
@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\PendingCalls\Concerns;
|
||||
|
||||
use Pest\Support\Description;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -12,14 +14,14 @@ trait Describable
|
||||
/**
|
||||
* Note: this is property is not used; however, it gets added automatically by rector php.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
public array $__describing;
|
||||
|
||||
/**
|
||||
* The describing of the test case.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
public array $describing = [];
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\PendingCalls;
|
||||
|
||||
use Closure;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\Description;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
@ -16,7 +16,7 @@ final class DescribeCall
|
||||
/**
|
||||
* The current describe call.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
private static array $describing = [];
|
||||
|
||||
@ -31,7 +31,7 @@ final class DescribeCall
|
||||
public function __construct(
|
||||
public readonly TestSuite $testSuite,
|
||||
public readonly string $filename,
|
||||
public readonly string $description,
|
||||
public readonly Description $description,
|
||||
public readonly Closure $tests
|
||||
) {
|
||||
//
|
||||
@ -40,7 +40,7 @@ final class DescribeCall
|
||||
/**
|
||||
* What is the current describing.
|
||||
*
|
||||
* @return array<int, string>
|
||||
* @return array<int, Description>
|
||||
*/
|
||||
public static function describing(): array
|
||||
{
|
||||
@ -52,7 +52,11 @@ final class DescribeCall
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
unset($this->currentBeforeEachCall);
|
||||
// Ensure BeforeEachCall destructs before creating tests
|
||||
// by moving to local scope and clearing the reference
|
||||
$beforeEach = $this->currentBeforeEachCall;
|
||||
$this->currentBeforeEachCall = null;
|
||||
unset($beforeEach); // Trigger destructor immediately
|
||||
|
||||
self::$describing[] = $this->description;
|
||||
|
||||
@ -70,15 +74,16 @@ final class DescribeCall
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
if (! $this->currentBeforeEachCall instanceof BeforeEachCall) {
|
||||
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $this->filename);
|
||||
|
||||
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) {
|
||||
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
|
||||
|
||||
$this->currentBeforeEachCall->describing[] = $this->description;
|
||||
$this->currentBeforeEachCall->describing = array_merge(
|
||||
DescribeCall::describing(),
|
||||
[$this->description]
|
||||
);
|
||||
}
|
||||
|
||||
$this->currentBeforeEachCall->{$name}(...$arguments); // @phpstan-ignore-line
|
||||
$this->currentBeforeEachCall->{$name}(...$arguments);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ use Pest\Factories\Attribute;
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
use Pest\Mutate\Repositories\ConfigurationRepository;
|
||||
use Pest\PendingCalls\Concerns\Describable;
|
||||
use Pest\Plugins\Environment;
|
||||
use Pest\Plugins\Only;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\Container;
|
||||
@ -21,6 +22,10 @@ use Pest\Support\NullClosure;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\CoversFunction;
|
||||
use PHPUnit\Framework\Attributes\CoversTrait;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
@ -178,10 +183,9 @@ final class TestCall // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the current test multiple times with
|
||||
* each item of the given `iterable`.
|
||||
* Runs the current test multiple times with each item of the given `iterable`.
|
||||
*
|
||||
* @param array<\Closure|iterable<int|string, mixed>|string> $data
|
||||
* @param Closure|iterable<array-key, mixed>|string $data
|
||||
*/
|
||||
public function with(Closure|iterable|string ...$data): self
|
||||
{
|
||||
@ -211,7 +215,7 @@ final class TestCall // @phpstan-ignore-line
|
||||
{
|
||||
foreach ($groups as $group) {
|
||||
$this->testCaseMethod->attributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\Group::class,
|
||||
Group::class,
|
||||
[$group],
|
||||
);
|
||||
}
|
||||
@ -224,7 +228,7 @@ final class TestCall // @phpstan-ignore-line
|
||||
*/
|
||||
public function only(): self
|
||||
{
|
||||
Only::enable($this, ...func_get_args()); // @phpstan-ignore-line
|
||||
Only::enable($this, ...func_get_args());
|
||||
|
||||
return $this;
|
||||
}
|
||||
@ -315,6 +319,61 @@ final class TestCall // @phpstan-ignore-line
|
||||
: $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weather the current test is running on a CI environment.
|
||||
*/
|
||||
private function runningOnCI(): bool
|
||||
{
|
||||
foreach ([
|
||||
'CI',
|
||||
'GITHUB_ACTIONS',
|
||||
'GITLAB_CI',
|
||||
'CIRCLECI',
|
||||
'TRAVIS',
|
||||
'APPVEYOR',
|
||||
'BITBUCKET_BUILD_NUMBER',
|
||||
'BUILDKITE',
|
||||
'TEAMCITY_VERSION',
|
||||
'JENKINS_URL',
|
||||
'SYSTEM_COLLECTIONURI',
|
||||
'CI_NAME',
|
||||
'TASKCLUSTER_ROOT_URL',
|
||||
'DRONE',
|
||||
'WERCKER',
|
||||
'NEVERCODE',
|
||||
'SEMAPHORE',
|
||||
'NETLIFY',
|
||||
'NOW_BUILDER',
|
||||
] as $env) {
|
||||
if (getenv($env) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Environment::name() === Environment::CI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips the current test when running on a CI environments.
|
||||
*/
|
||||
public function skipOnCI(): self
|
||||
{
|
||||
if ($this->runningOnCI()) {
|
||||
return $this->skip('This test is skipped on [CI].');
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function skipLocally(): self
|
||||
{
|
||||
if ($this->runningOnCI() === false) {
|
||||
return $this->skip('This test is skipped [locally].');
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips the current test unless the given test is running on Windows.
|
||||
*/
|
||||
@ -353,6 +412,20 @@ final class TestCall // @phpstan-ignore-line
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the test as flaky, retrying it up to the given number of times.
|
||||
*/
|
||||
public function flaky(int $tries = 3): self
|
||||
{
|
||||
if ($tries < 1) {
|
||||
throw new InvalidArgumentException('The number of tries must be greater than 0.');
|
||||
}
|
||||
|
||||
$this->testCaseMethod->flakyTries = $tries;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the test as "todo".
|
||||
*/
|
||||
@ -549,7 +622,7 @@ final class TestCall // @phpstan-ignore-line
|
||||
{
|
||||
foreach ($classes as $class) {
|
||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversClass::class,
|
||||
CoversClass::class,
|
||||
[$class],
|
||||
);
|
||||
}
|
||||
@ -572,7 +645,7 @@ final class TestCall // @phpstan-ignore-line
|
||||
{
|
||||
foreach ($traits as $trait) {
|
||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversTrait::class,
|
||||
CoversTrait::class,
|
||||
[$trait],
|
||||
);
|
||||
}
|
||||
@ -595,7 +668,7 @@ final class TestCall // @phpstan-ignore-line
|
||||
{
|
||||
foreach ($functions as $function) {
|
||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversFunction::class,
|
||||
CoversFunction::class,
|
||||
[$function],
|
||||
);
|
||||
}
|
||||
@ -604,18 +677,29 @@ final class TestCall // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets that the current test covers nothing.
|
||||
* Adds one or more references to the tested method or class. This helps
|
||||
* to link test cases to the source code for easier navigation.
|
||||
*
|
||||
* @param array<class-string|string>|class-string ...$classes
|
||||
*/
|
||||
public function coversNothing(): self
|
||||
public function references(string|array ...$classes): self
|
||||
{
|
||||
$this->testCaseMethod->attributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversNothing::class,
|
||||
[],
|
||||
);
|
||||
assert($classes !== []);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more references to the tested method or class. This helps
|
||||
* to link test cases to the source code for easier navigation.
|
||||
*
|
||||
* @param array<class-string|string>|class-string ...$classes
|
||||
*/
|
||||
public function see(string|array ...$classes): self
|
||||
{
|
||||
return $this->references(...$classes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Informs the test runner that no expectations happen in this test,
|
||||
* and its purpose is simply to check whether the given code can
|
||||
@ -693,7 +777,12 @@ final class TestCall // @phpstan-ignore-line
|
||||
$this->testSuite->tests->set($this->testCaseMethod);
|
||||
|
||||
if (! is_null($testCase = $this->testSuite->tests->get($this->filename))) {
|
||||
$testCase->attributes = array_merge($testCase->attributes, $this->testCaseFactoryAttributes);
|
||||
$attributesToMerge = array_filter(
|
||||
$this->testCaseFactoryAttributes,
|
||||
fn (Attribute $attributeToMerge): bool => array_filter($testCase->attributes, fn (Attribute $attribute): bool => serialize($attributeToMerge) === serialize($attribute)) === []
|
||||
);
|
||||
|
||||
$testCase->attributes = array_merge($testCase->attributes, $attributesToMerge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '3.5.2';
|
||||
return '4.6.1';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@ -21,7 +21,7 @@ final class Cache implements HandlesArguments
|
||||
/**
|
||||
* The temporary folder.
|
||||
*/
|
||||
private const TEMPORARY_FOLDER = __DIR__
|
||||
private const string TEMPORARY_FOLDER = __DIR__
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'..'
|
||||
.DIRECTORY_SEPARATOR
|
||||
|
||||
@ -56,4 +56,31 @@ trait HandleArguments
|
||||
|
||||
return array_values(array_flip($arguments));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the given argument and its value from the arguments, returning the value.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function popArgumentValue(string $argument, array &$arguments): ?string
|
||||
{
|
||||
foreach ($arguments as $key => $value) {
|
||||
if (str_contains($value, "$argument=")) {
|
||||
unset($arguments[$key]);
|
||||
$arguments = array_values($arguments);
|
||||
|
||||
return substr($value, strlen($argument) + 1);
|
||||
}
|
||||
|
||||
if ($value === $argument && isset($arguments[$key + 1])) {
|
||||
$result = $arguments[$key + 1];
|
||||
unset($arguments[$key], $arguments[$key + 1]);
|
||||
$arguments = array_values($arguments);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ final class Configuration implements HandlesArguments, Terminable
|
||||
/**
|
||||
* The base PHPUnit file.
|
||||
*/
|
||||
public const BASE_PHPUNIT_FILE = __DIR__
|
||||
public const string BASE_PHPUNIT_FILE = __DIR__
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'..'
|
||||
.DIRECTORY_SEPARATOR
|
||||
@ -34,7 +34,7 @@ final class Configuration implements HandlesArguments, Terminable
|
||||
*/
|
||||
public function handleArguments(array $arguments): array
|
||||
{
|
||||
if ($this->hasArgument('--configuration', $arguments) || $this->hasCustomConfigurationFile()) {
|
||||
if ($this->hasArgument('--configuration', $arguments) || $this->hasArgument('-c', $arguments) || $this->hasCustomConfigurationFile()) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
|
||||
@ -17,26 +17,39 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class Coverage implements AddsOutput, HandlesArguments
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const COVERAGE_OPTION = 'coverage';
|
||||
private const string COVERAGE_OPTION = 'coverage';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const MIN_OPTION = 'min';
|
||||
private const string MIN_OPTION = 'min';
|
||||
|
||||
private const string EXACTLY_OPTION = 'exactly';
|
||||
|
||||
private const string ONLY_COVERED_OPTION = 'only-covered';
|
||||
|
||||
/**
|
||||
* Whether it should show the coverage or not.
|
||||
*/
|
||||
public bool $coverage = false;
|
||||
|
||||
/**
|
||||
* Whether it should show the coverage or not.
|
||||
*/
|
||||
public bool $compact = false;
|
||||
|
||||
/**
|
||||
* The minimum coverage.
|
||||
*/
|
||||
public float $coverageMin = 0.0;
|
||||
|
||||
/**
|
||||
* The exactly coverage.
|
||||
*/
|
||||
public ?float $coverageExactly = null;
|
||||
|
||||
/**
|
||||
* Whether it should show only covered files.
|
||||
*/
|
||||
public bool $showOnlyCovered = false;
|
||||
|
||||
/**
|
||||
* Creates a new Plugin instance.
|
||||
*/
|
||||
@ -51,7 +64,7 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
public function handleArguments(array $originals): array
|
||||
{
|
||||
$arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool {
|
||||
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION] as $option) {
|
||||
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) {
|
||||
if ($original === sprintf('--%s', $option)) {
|
||||
return true;
|
||||
}
|
||||
@ -73,6 +86,8 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
$inputs = [];
|
||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
|
||||
$inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED);
|
||||
$inputs[] = new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE);
|
||||
|
||||
$input = new ArgvInput($arguments, new InputDefinition($inputs));
|
||||
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
|
||||
@ -106,6 +121,21 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
$this->coverageMin = (float) $minOption;
|
||||
}
|
||||
|
||||
if ($input->getOption(self::EXACTLY_OPTION) !== null) {
|
||||
/** @var int|float $exactlyOption */
|
||||
$exactlyOption = $input->getOption(self::EXACTLY_OPTION);
|
||||
|
||||
$this->coverageExactly = (float) $exactlyOption;
|
||||
}
|
||||
|
||||
if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) {
|
||||
$this->showOnlyCovered = true;
|
||||
}
|
||||
|
||||
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
|
||||
$this->compact = true;
|
||||
}
|
||||
|
||||
return $originals;
|
||||
}
|
||||
|
||||
@ -126,11 +156,23 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$coverage = \Pest\Support\Coverage::report($this->output);
|
||||
|
||||
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered);
|
||||
$exitCode = (int) ($coverage < $this->coverageMin);
|
||||
|
||||
if ($exitCode === 1) {
|
||||
if ($exitCode === 0 && $this->coverageExactly !== null) {
|
||||
$comparableCoverage = $this->computeComparableCoverage($coverage);
|
||||
$comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly);
|
||||
|
||||
$exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1;
|
||||
|
||||
if ($exitCode === 1) {
|
||||
$this->output->writeln(sprintf(
|
||||
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage not exactly <fg=white;options=bold> %s %%</>, currently <fg=red;options=bold> %s %%</>.",
|
||||
number_format($this->coverageExactly, 1),
|
||||
number_format(floor($coverage * 10) / 10, 1),
|
||||
));
|
||||
}
|
||||
} elseif ($exitCode === 1) {
|
||||
$this->output->writeln(sprintf(
|
||||
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected <fg=white;options=bold> %s %%</>, currently <fg=red;options=bold> %s %%</>.",
|
||||
number_format($this->coverageMin, 1),
|
||||
@ -143,4 +185,12 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the comparable coverage to a percentage with one decimal.
|
||||
*/
|
||||
private function computeComparableCoverage(float $coverage): float
|
||||
{
|
||||
return floor($coverage * 10) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,12 +14,12 @@ final class Environment implements HandlesArguments
|
||||
/**
|
||||
* The continuous integration environment.
|
||||
*/
|
||||
public const CI = 'ci';
|
||||
public const string CI = 'ci';
|
||||
|
||||
/**
|
||||
* The local environment.
|
||||
*/
|
||||
public const LOCAL = 'local';
|
||||
public const string LOCAL = 'local';
|
||||
|
||||
/**
|
||||
* The current environment.
|
||||
|
||||
@ -99,6 +99,7 @@ final readonly class Help implements HandlesArguments
|
||||
{
|
||||
$helpReflection = new PHPUnitHelp;
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$content = (fn (): array => $this->elements())->call($helpReflection);
|
||||
|
||||
$content['Configuration'] = [...[[
|
||||
@ -106,6 +107,13 @@ final readonly class Help implements HandlesArguments
|
||||
'desc' => 'Initialise a standard Pest configuration',
|
||||
]], ...$content['Configuration']];
|
||||
|
||||
$content['AI'] = [
|
||||
[
|
||||
'arg' => '--ai',
|
||||
'desc' => 'Run a code snippet as a fully scaffolded test for AI verification',
|
||||
],
|
||||
];
|
||||
|
||||
$content['Execution'] = [...[
|
||||
[
|
||||
'arg' => '--parallel',
|
||||
@ -115,6 +123,10 @@ final readonly class Help implements HandlesArguments
|
||||
'arg' => '--update-snapshots',
|
||||
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
|
||||
],
|
||||
[
|
||||
'arg' => '--update-shards',
|
||||
'desc' => 'Update shards.json with test timing data for time-balanced sharding',
|
||||
],
|
||||
], ...$content['Execution']];
|
||||
|
||||
$content['Selection'] = [[
|
||||
@ -141,6 +153,12 @@ final readonly class Help implements HandlesArguments
|
||||
], [
|
||||
'arg' => '--retry',
|
||||
'desc' => 'Run non-passing tests first and stop execution upon first error or failure',
|
||||
], [
|
||||
'arg' => '--dirty',
|
||||
'desc' => 'Only run tests that have uncommitted changes according to Git',
|
||||
], [
|
||||
'arg' => '--flaky',
|
||||
'desc' => 'Output to standard output tests marked as flaky',
|
||||
], ...$content['Selection']];
|
||||
|
||||
$content['Reporting'] = [...$content['Reporting'], ...[
|
||||
@ -156,6 +174,12 @@ final readonly class Help implements HandlesArguments
|
||||
], [
|
||||
'arg' => '--coverage --min',
|
||||
'desc' => 'Set the minimum required coverage percentage, and fail if not met',
|
||||
], [
|
||||
'arg' => '--coverage --exactly',
|
||||
'desc' => 'Set the exact required coverage percentage, and fail if not met',
|
||||
], [
|
||||
'arg' => '--coverage --only-covered',
|
||||
'desc' => 'Hide files with 0% coverage from the code coverage report',
|
||||
], ...$content['Code Coverage']];
|
||||
|
||||
$content['Mutation Testing'] = [[
|
||||
|
||||
@ -20,12 +20,12 @@ final readonly class Init implements HandlesArguments
|
||||
/**
|
||||
* The option the triggers the init job.
|
||||
*/
|
||||
private const INIT_OPTION = '--init';
|
||||
private const string INIT_OPTION = '--init';
|
||||
|
||||
/**
|
||||
* The files that will be created.
|
||||
*/
|
||||
private const STUBS = [
|
||||
private const array STUBS = [
|
||||
'phpunit.xml.stub' => 'phpunit.xml',
|
||||
'Pest.php.stub' => 'tests/Pest.php',
|
||||
'TestCase.php.stub' => 'tests/TestCase.php',
|
||||
@ -119,6 +119,6 @@ final readonly class Init implements HandlesArguments
|
||||
*/
|
||||
private function isLaravelInstalled(): bool
|
||||
{
|
||||
return InstalledVersions::isInstalled('laravel/laravel');
|
||||
return InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Factories\Attribute;
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
use Pest\PendingCalls\TestCall;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@ -15,7 +18,7 @@ final class Only implements Terminable
|
||||
/**
|
||||
* The temporary folder.
|
||||
*/
|
||||
private const TEMPORARY_FOLDER = __DIR__
|
||||
private const string TEMPORARY_FOLDER = __DIR__
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'..'
|
||||
.DIRECTORY_SEPARATOR
|
||||
@ -23,28 +26,19 @@ final class Only implements Terminable
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'.temp';
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
if (Parallel::isWorker()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
unlink($lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the lock file.
|
||||
*/
|
||||
public static function enable(TestCall $testCall, string $group = '__pest_only'): void
|
||||
public static function enable(TestCall|TestCaseMethodFactory $testCall, string $group = '__pest_only'): void
|
||||
{
|
||||
$testCall->group($group);
|
||||
if ($testCall instanceof TestCall) {
|
||||
$testCall->group($group);
|
||||
} else {
|
||||
$testCall->attributes[] = new Attribute(
|
||||
Group::class,
|
||||
[$group],
|
||||
);
|
||||
}
|
||||
|
||||
if (Environment::name() === Environment::CI || Parallel::isWorker()) {
|
||||
return;
|
||||
@ -88,4 +82,20 @@ final class Only implements Terminable
|
||||
|
||||
return file_get_contents($lockFile) ?: '__pest_only'; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
if (Parallel::isWorker()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
|
||||
|
||||
if (file_exists($lockFile)) {
|
||||
unlink($lockFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,9 +23,9 @@ final class Parallel implements HandlesArguments
|
||||
{
|
||||
use HandleArguments;
|
||||
|
||||
private const GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
|
||||
private const string GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
|
||||
|
||||
private const HANDLERS = [
|
||||
private const array HANDLERS = [
|
||||
Parallel\Handlers\Parallel::class,
|
||||
Parallel\Handlers\Pest::class,
|
||||
Parallel\Handlers\Laravel::class,
|
||||
@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
private const UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request'];
|
||||
private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request', '--flaky'];
|
||||
|
||||
/**
|
||||
* Whether the given command line arguments indicate that the test suite should be run in parallel.
|
||||
@ -127,7 +127,9 @@ final class Parallel implements HandlesArguments
|
||||
$arguments
|
||||
);
|
||||
|
||||
$exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput);
|
||||
$filteredArguments = $this->processTeamcityArguments($filteredArguments);
|
||||
|
||||
$exitCode = $this->paratestCommand()->run(new ArgvInput(array_values($filteredArguments)), new CleanConsoleOutput);
|
||||
|
||||
return CallsAddsOutput::execute($exitCode);
|
||||
}
|
||||
@ -197,4 +199,18 @@ final class Parallel implements HandlesArguments
|
||||
|
||||
return $this->popArgument('-p', $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $arguments
|
||||
* @return string[]
|
||||
*/
|
||||
public function processTeamcityArguments(array $arguments): array
|
||||
{
|
||||
$argv = new ArgvInput;
|
||||
if ($argv->hasParameterOption('--teamcity')) {
|
||||
$arguments[] = '--teamcity';
|
||||
}
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Handlers;
|
||||
use Closure;
|
||||
use Composer\InstalledVersions;
|
||||
use Illuminate\Testing\ParallelRunner;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use ParaTest\Options;
|
||||
use ParaTest\RunnerInterface;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
@ -39,13 +40,13 @@ final class Laravel implements HandlesArguments
|
||||
* Executes the given closure when running Laravel.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
* @param CLosure(array<int, string>): array<int, string> $closure
|
||||
* @param Closure(array<int, string>): array<int, string> $closure
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function whenUsingLaravel(array $arguments, Closure $closure): array
|
||||
{
|
||||
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
|
||||
$isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class);
|
||||
$isLaravelPackage = class_exists(TestCase::class);
|
||||
|
||||
if ($isLaravelApplication && ! $isLaravelPackage) {
|
||||
return $closure($arguments);
|
||||
|
||||
@ -18,7 +18,7 @@ final class Parallel implements HandlesArguments
|
||||
/**
|
||||
* The list of arguments to remove.
|
||||
*/
|
||||
private const ARGS_TO_REMOVE = [
|
||||
private const array ARGS_TO_REMOVE = [
|
||||
'--parallel',
|
||||
'-p',
|
||||
'--no-output',
|
||||
|
||||
@ -11,6 +11,7 @@ final class CleanConsoleOutput extends ConsoleOutput
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
#[\Override]
|
||||
protected function doWrite(string $message, bool $newline): void // @pest-arch-ignore-line
|
||||
{
|
||||
if ($this->isOpeningHeadline($message)) {
|
||||
|
||||
@ -59,10 +59,10 @@ final class ResultPrinter
|
||||
private readonly OutputInterface $output,
|
||||
private readonly Options $options
|
||||
) {
|
||||
$this->printer = new class($this->output) implements Printer
|
||||
$this->printer = new readonly class($this->output) implements Printer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
private OutputInterface $output,
|
||||
) {}
|
||||
|
||||
public function print(string $buffer): void
|
||||
@ -81,7 +81,9 @@ final class ResultPrinter
|
||||
public function flush(): void {}
|
||||
};
|
||||
|
||||
$this->compactPrinter = CompactPrinter::default();
|
||||
$this->compactPrinter = CompactPrinter::default(
|
||||
decorated: ! in_array('--colors=never', $_SERVER['argv'] ?? [], true),
|
||||
);
|
||||
|
||||
if (! $this->options->configuration->hasLogfileTeamcity()) {
|
||||
return;
|
||||
@ -92,14 +94,13 @@ final class ResultPrinter
|
||||
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
|
||||
}
|
||||
|
||||
/** @param list<SplFileInfo> $teamcityFiles */
|
||||
public function printFeedback(
|
||||
SplFileInfo $progressFile,
|
||||
SplFileInfo $outputFile,
|
||||
array $teamcityFiles
|
||||
?SplFileInfo $teamcityFile,
|
||||
): void {
|
||||
if ($this->options->needsTeamcity) {
|
||||
$teamcityProgress = $this->tailMultiple($teamcityFiles);
|
||||
if ($this->options->needsTeamcity && $teamcityFile instanceof SplFileInfo) {
|
||||
$teamcityProgress = $this->tailMultiple([$teamcityFile]);
|
||||
|
||||
if ($this->teamcityLogFileHandle !== null) {
|
||||
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
|
||||
@ -171,8 +172,18 @@ final class ResultPrinter
|
||||
|
||||
$state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult);
|
||||
|
||||
$this->compactPrinter->errors($state);
|
||||
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
|
||||
if ($testResult->numberOfTestsRun() === 0 && $state->testSuiteTestsCount() === 0) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! isset($_SERVER['PEST_PARALLEL_NO_OUTPUT'])) {
|
||||
$this->compactPrinter->errors($state);
|
||||
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
|
||||
}
|
||||
}
|
||||
|
||||
private function printFeedbackItem(string $item): void
|
||||
|
||||
@ -17,8 +17,10 @@ use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Result;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event\Facade as EventFacade;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
use PHPUnit\Event\TestRunner\WarningTriggered;
|
||||
use PHPUnit\Runner\CodeCoverage;
|
||||
use PHPUnit\Runner\ResultCache\DefaultResultCache;
|
||||
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||
@ -37,6 +39,7 @@ use function dirname;
|
||||
use function file_get_contents;
|
||||
use function max;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
use function unlink;
|
||||
use function unserialize;
|
||||
use function usleep;
|
||||
@ -49,7 +52,12 @@ final class WrapperRunner implements RunnerInterface
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
private const CYCLE_SLEEP = 10000;
|
||||
/**
|
||||
* The merged test result from the parallel run.
|
||||
*/
|
||||
public static ?TestResult $result = null;
|
||||
|
||||
private const int CYCLE_SLEEP = 10000;
|
||||
|
||||
/**
|
||||
* The result printer.
|
||||
@ -79,7 +87,10 @@ final class WrapperRunner implements RunnerInterface
|
||||
private array $unexpectedOutputFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $testresultFiles = [];
|
||||
private array $resultCacheFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $testResultFiles = [];
|
||||
|
||||
/** @var list<SplFileInfo> */
|
||||
private array $coverageFiles = [];
|
||||
@ -126,6 +137,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
$parameters = $this->handleLaravelHerd($parameters);
|
||||
|
||||
$parameters[] = $wrapper;
|
||||
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||
|
||||
$this->parameters = $parameters;
|
||||
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry;
|
||||
@ -220,7 +232,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
$this->printer->printFeedback(
|
||||
$worker->progressFile,
|
||||
$worker->unexpectedOutputFile,
|
||||
$this->teamcityFiles,
|
||||
$worker->teamcityFile ?? null,
|
||||
);
|
||||
$worker->reset();
|
||||
}
|
||||
@ -264,7 +276,8 @@ final class WrapperRunner implements RunnerInterface
|
||||
$this->batches[$token] = 0;
|
||||
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->testresultFiles[] = $worker->testresultFile;
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->testResultFiles[] = $worker->testResultFile;
|
||||
|
||||
if (isset($worker->junitFile)) {
|
||||
$this->junitFiles[] = $worker->junitFile;
|
||||
@ -298,37 +311,52 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
private function complete(TestResult $testResultSum): int
|
||||
{
|
||||
foreach ($this->testresultFiles as $testresultFile) {
|
||||
if (! $testresultFile->isFile()) {
|
||||
foreach ($this->testResultFiles as $testResultFile) {
|
||||
if (! $testResultFile->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($testresultFile->getPathname());
|
||||
$contents = file_get_contents($testResultFile->getPathname());
|
||||
assert($contents !== false);
|
||||
$testResult = unserialize($contents);
|
||||
assert($testResult instanceof TestResult);
|
||||
|
||||
/** @var list<AfterLastTestMethodFailed> $failedEvents */
|
||||
$failedEvents = array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents());
|
||||
|
||||
$testResultSum = new TestResult(
|
||||
(int) $testResultSum->hasTests() + (int) $testResult->hasTests(),
|
||||
$testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
|
||||
$testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(),
|
||||
array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()),
|
||||
array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()),
|
||||
$failedEvents,
|
||||
array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()),
|
||||
array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()),
|
||||
array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()),
|
||||
array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()),
|
||||
array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
|
||||
array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
|
||||
array_merge_recursive($testResultSum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()),
|
||||
array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredNoticeEvents(), $testResult->testRunnerTriggeredNoticeEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->notices(), $testResult->notices()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->warnings(), $testResult->warnings()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
|
||||
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
|
||||
);
|
||||
@ -346,8 +374,10 @@ final class WrapperRunner implements RunnerInterface
|
||||
$testResultSum->testMarkedIncompleteEvents(),
|
||||
$testResultSum->testTriggeredPhpunitDeprecationEvents(),
|
||||
$testResultSum->testTriggeredPhpunitErrorEvents(),
|
||||
$testResultSum->testTriggeredPhpunitNoticeEvents(),
|
||||
$testResultSum->testTriggeredPhpunitWarningEvents(),
|
||||
$testResultSum->testRunnerTriggeredDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||
array_values(array_filter(
|
||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
||||
@ -360,9 +390,22 @@ final class WrapperRunner implements RunnerInterface
|
||||
$testResultSum->phpNotices(),
|
||||
$testResultSum->phpWarnings(),
|
||||
$testResultSum->numberOfIssuesIgnoredByBaseline(),
|
||||
|
||||
);
|
||||
|
||||
self::$result = $testResultSum;
|
||||
|
||||
if ($this->options->configuration->cacheResult()) {
|
||||
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
|
||||
foreach ($this->resultCacheFiles as $resultCacheFile) {
|
||||
$resultCache = new DefaultResultCache($resultCacheFile->getPathname());
|
||||
$resultCache->load();
|
||||
|
||||
$resultCacheSum->mergeWith($resultCache);
|
||||
}
|
||||
|
||||
$resultCacheSum->persist();
|
||||
}
|
||||
|
||||
$this->printer->printResults(
|
||||
$testResultSum,
|
||||
$this->teamcityFiles,
|
||||
@ -375,7 +418,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||
|
||||
$this->clearFiles($this->unexpectedOutputFiles);
|
||||
$this->clearFiles($this->testresultFiles);
|
||||
$this->clearFiles($this->testResultFiles);
|
||||
$this->clearFiles($this->coverageFiles);
|
||||
$this->clearFiles($this->junitFiles);
|
||||
$this->clearFiles($this->teamcityFiles);
|
||||
@ -449,15 +492,61 @@ final class WrapperRunner implements RunnerInterface
|
||||
*/
|
||||
private function getTestFiles(SuiteLoader $suiteLoader): array
|
||||
{
|
||||
/** @var array<string, non-empty-string> $files */
|
||||
$files = [
|
||||
...array_values(array_filter(
|
||||
$suiteLoader->tests,
|
||||
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
|
||||
)),
|
||||
...TestSuite::getInstance()->tests->getFilenames(),
|
||||
];
|
||||
/** @var array<string, null> $files */
|
||||
$files = [];
|
||||
|
||||
return $files; // @phpstan-ignore-line
|
||||
foreach (array_filter(
|
||||
$suiteLoader->tests,
|
||||
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
|
||||
) as $filename) {
|
||||
$resolved = realpath($filename) ?: $filename;
|
||||
$files[$resolved] = null;
|
||||
}
|
||||
|
||||
foreach (TestSuite::getInstance()->tests->getFilenames() as $filename) {
|
||||
if ($this->shouldIncludeBootstrappedTestFile($filename)) {
|
||||
$resolved = realpath($filename)
|
||||
?: realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename)
|
||||
?: $filename;
|
||||
$files[$resolved] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($files); // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
private function shouldIncludeBootstrappedTestFile(string $filename): bool
|
||||
{
|
||||
if (! $this->options->configuration->hasCliArguments()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$resolvedFilename = realpath($filename);
|
||||
|
||||
if ($resolvedFilename === false) {
|
||||
$resolvedFilename = realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename);
|
||||
}
|
||||
|
||||
if ($resolvedFilename === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->options->configuration->cliArguments() as $path) {
|
||||
$resolvedPath = realpath($path);
|
||||
|
||||
if ($resolvedPath === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($resolvedFilename === $resolvedPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_dir($resolvedPath) && str_starts_with($resolvedFilename, $resolvedPath.DIRECTORY_SEPARATOR)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ final class CompactPrinter
|
||||
/**
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
private const LOOKUP_TABLE = [
|
||||
private const array LOOKUP_TABLE = [
|
||||
'.' => ['gray', '.'],
|
||||
'S' => ['yellow', 's'],
|
||||
'T' => ['cyan', 't'],
|
||||
@ -62,12 +62,12 @@ final class CompactPrinter
|
||||
/**
|
||||
* Creates a new instance of the Compact Printer.
|
||||
*/
|
||||
public static function default(): self
|
||||
public static function default(bool $decorated = true): self
|
||||
{
|
||||
return new self(
|
||||
terminal(),
|
||||
new ConsoleOutput(decorated: true),
|
||||
new Style(new ConsoleOutput(decorated: true)),
|
||||
new ConsoleOutput(decorated: $decorated),
|
||||
new Style(new ConsoleOutput(decorated: $decorated)),
|
||||
terminal()->width() - 4,
|
||||
);
|
||||
}
|
||||
@ -131,14 +131,14 @@ final class CompactPrinter
|
||||
$status['collected'],
|
||||
$status['threshold'],
|
||||
$status['roots'],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0.00,
|
||||
0.00,
|
||||
0.00,
|
||||
0.00,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
|
||||
$telemetry = new Info(
|
||||
|
||||
530
src/Plugins/Shard.php
Normal file
530
src/Plugins/Shard.php
Normal file
@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Exceptions\InvalidOption;
|
||||
use Pest\Subscribers\EnsureShardTimingFinished;
|
||||
use Pest\Subscribers\EnsureShardTimingsAreCollected;
|
||||
use Pest\Subscribers\EnsureShardTimingStarted;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
private const string SHARD_OPTION = 'shard';
|
||||
|
||||
/**
|
||||
* The shard index and total number of shards.
|
||||
*
|
||||
* @var array{
|
||||
* index: int,
|
||||
* total: int,
|
||||
* testsRan: int,
|
||||
* testsCount: int
|
||||
* }|null
|
||||
*/
|
||||
private static ?array $shard = null;
|
||||
|
||||
/**
|
||||
* Whether to update the shards.json file.
|
||||
*/
|
||||
private static bool $updateShards = false;
|
||||
|
||||
/**
|
||||
* Whether time-balanced sharding was used.
|
||||
*/
|
||||
private static bool $timeBalanced = false;
|
||||
|
||||
/**
|
||||
* Whether the shards.json file is outdated.
|
||||
*/
|
||||
private static bool $shardsOutdated = false;
|
||||
|
||||
/**
|
||||
* Whether the test suite passed.
|
||||
*/
|
||||
private static bool $passed = false;
|
||||
|
||||
/**
|
||||
* Collected timings from workers or subscribers.
|
||||
*
|
||||
* @var array<string, float>|null
|
||||
*/
|
||||
private static ?array $collectedTimings = null;
|
||||
|
||||
/**
|
||||
* The canonical list of test classes from --list-tests.
|
||||
*
|
||||
* @var list<string>|null
|
||||
*/
|
||||
private static ?array $knownTests = null;
|
||||
|
||||
/**
|
||||
* Creates a new Plugin instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function handleArguments(array $arguments): array
|
||||
{
|
||||
if ($this->hasArgument('--update-shards', $arguments)) {
|
||||
return $this->handleUpdateShards($arguments);
|
||||
}
|
||||
|
||||
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
|
||||
self::$updateShards = true;
|
||||
|
||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
|
||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--shard', $arguments)) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$input = new ArgvInput($arguments);
|
||||
|
||||
['index' => $index, 'total' => $total] = self::getShard($input);
|
||||
|
||||
$arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument(
|
||||
"$index/$total",
|
||||
$arguments,
|
||||
)));
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$tests = $this->allTests($arguments);
|
||||
|
||||
$timings = $this->loadShardsFile();
|
||||
if ($timings !== null) {
|
||||
$knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
|
||||
$newTests = array_values(array_diff($tests, $knownTests));
|
||||
|
||||
$partitions = $this->partitionByTime($knownTests, $timings, $total);
|
||||
|
||||
foreach ($newTests as $i => $test) {
|
||||
$partitions[$i % $total][] = $test;
|
||||
}
|
||||
|
||||
$testsToRun = $partitions[$index - 1] ?? [];
|
||||
self::$timeBalanced = true;
|
||||
self::$shardsOutdated = $newTests !== [];
|
||||
} else {
|
||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||
}
|
||||
|
||||
self::$shard = [
|
||||
'index' => $index,
|
||||
'total' => $total,
|
||||
'testsRan' => count($testsToRun),
|
||||
'testsCount' => count($tests),
|
||||
];
|
||||
|
||||
if ($testsToRun === []) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the --update-shards argument.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function handleUpdateShards(array $arguments): array
|
||||
{
|
||||
if ($this->hasArgument('--shard', $arguments)) {
|
||||
throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
|
||||
}
|
||||
|
||||
$arguments = $this->popArgument('--update-shards', $arguments);
|
||||
|
||||
self::$updateShards = true;
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
self::$knownTests = $this->allTests($arguments);
|
||||
|
||||
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
|
||||
Parallel::setGlobal('UPDATE_SHARDS', true);
|
||||
Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true));
|
||||
} else {
|
||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
|
||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
|
||||
}
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all tests that the test suite would run.
|
||||
*
|
||||
* @param list<string> $arguments
|
||||
* @return list<string>
|
||||
*/
|
||||
private function allTests(array $arguments): array
|
||||
{
|
||||
$output = (new Process([
|
||||
'php',
|
||||
...$this->removeParallelArguments($arguments),
|
||||
'--list-tests',
|
||||
]))->setTimeout(120)->mustRun()->getOutput();
|
||||
|
||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function removeParallelArguments(array $arguments): array
|
||||
{
|
||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the filter argument for the given tests to run.
|
||||
*/
|
||||
private function buildFilterArgument(mixed $testsToRun): string
|
||||
{
|
||||
return addslashes(implode('|', $testsToRun));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds output after the Test Suite execution.
|
||||
*/
|
||||
public function addOutput(int $exitCode): int
|
||||
{
|
||||
self::$passed = $exitCode === 0;
|
||||
|
||||
if (self::$updateShards && self::$passed && ! Parallel::isWorker()) {
|
||||
self::$collectedTimings = $this->collectTimings();
|
||||
|
||||
$count = self::$knownTests !== null
|
||||
? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
|
||||
: count(self::$collectedTimings);
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=gray>Shards:</> <fg=default>shards.json updated with timings for %d test class%s.</>',
|
||||
$count,
|
||||
$count === 1 ? '' : 'es',
|
||||
));
|
||||
}
|
||||
|
||||
if (self::$shard === null) {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
[
|
||||
'index' => $index,
|
||||
'total' => $total,
|
||||
'testsRan' => $testsRan,
|
||||
'testsCount' => $testsCount,
|
||||
] = self::$shard;
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d%s.',
|
||||
$index,
|
||||
$total,
|
||||
$testsRan,
|
||||
$testsRan === 1 ? '' : 's',
|
||||
$testsCount,
|
||||
self::$timeBalanced ? ' <fg=gray>(time-balanced)</>' : '',
|
||||
));
|
||||
|
||||
if (self::$shardsOutdated) {
|
||||
$this->output->writeln(' <fg=yellow;options=bold>WARN</> <fg=default>The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.</>');
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates the plugin.
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
if (! self::$updateShards) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Parallel::isWorker()) {
|
||||
$this->writeWorkerTimings();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::$passed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$timings = self::$collectedTimings ?? $this->collectTimings();
|
||||
|
||||
if ($timings === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->writeTimings($timings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects timings from subscribers or worker temp files.
|
||||
*
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function collectTimings(): array
|
||||
{
|
||||
$runId = Parallel::getGlobal('SHARD_RUN_ID');
|
||||
|
||||
if (is_string($runId)) {
|
||||
return $this->readWorkerTimings($runId);
|
||||
}
|
||||
|
||||
return EnsureShardTimingsAreCollected::timings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current worker's timing data to a temp file.
|
||||
*/
|
||||
private function writeWorkerTimings(): void
|
||||
{
|
||||
$timings = EnsureShardTimingsAreCollected::timings();
|
||||
|
||||
if ($timings === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$runId = Parallel::getGlobal('SHARD_RUN_ID');
|
||||
|
||||
if (! is_string($runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';
|
||||
|
||||
file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and merges timing data from all worker temp files.
|
||||
*
|
||||
* @return array<string, float>
|
||||
*/
|
||||
private function readWorkerTimings(string $runId): array
|
||||
{
|
||||
$pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
|
||||
$files = glob($pattern);
|
||||
|
||||
if ($files === false || $files === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$merged = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if ($contents === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$timings = json_decode($contents, true);
|
||||
|
||||
if (is_array($timings)) {
|
||||
$merged = array_merge($merged, $timings);
|
||||
}
|
||||
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to shards.json.
|
||||
*/
|
||||
private function shardsPath(): string
|
||||
{
|
||||
$testSuite = TestSuite::getInstance();
|
||||
|
||||
return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the timings from shards.json.
|
||||
*
|
||||
* @return array<string, float>|null
|
||||
*/
|
||||
private function loadShardsFile(): ?array
|
||||
{
|
||||
$path = $this->shardsPath();
|
||||
|
||||
if (! file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
|
||||
}
|
||||
|
||||
$data = json_decode($contents, true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['timings']) || ! is_array($data['timings'])) {
|
||||
throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
|
||||
}
|
||||
|
||||
return $data['timings'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions tests across shards using the LPT (Longest Processing Time) algorithm.
|
||||
*
|
||||
* @param list<string> $tests
|
||||
* @param array<string, float> $timings
|
||||
* @return list<list<string>>
|
||||
*/
|
||||
private function partitionByTime(array $tests, array $timings, int $total): array
|
||||
{
|
||||
$knownTimings = array_filter(
|
||||
array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
|
||||
fn (?float $t): bool => $t !== null,
|
||||
);
|
||||
|
||||
$median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;
|
||||
|
||||
$testsWithTimings = array_map(
|
||||
fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
|
||||
$tests,
|
||||
);
|
||||
|
||||
usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);
|
||||
|
||||
/** @var list<list<string>> */
|
||||
$bins = array_fill(0, $total, []);
|
||||
/** @var non-empty-list<float> */
|
||||
$binTimes = array_fill(0, $total, 0.0);
|
||||
|
||||
foreach ($testsWithTimings as $item) {
|
||||
$minIndex = array_search(min($binTimes), $binTimes, strict: true);
|
||||
assert(is_int($minIndex));
|
||||
|
||||
$bins[$minIndex][] = $item['test'];
|
||||
$binTimes[$minIndex] += $item['time'];
|
||||
}
|
||||
|
||||
return $bins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the median of an array of floats.
|
||||
*
|
||||
* @param list<float> $values
|
||||
*/
|
||||
private function median(array $values): float
|
||||
{
|
||||
sort($values);
|
||||
|
||||
$count = count($values);
|
||||
$middle = (int) floor($count / 2);
|
||||
|
||||
if ($count % 2 === 0) {
|
||||
return ($values[$middle - 1] + $values[$middle]) / 2;
|
||||
}
|
||||
|
||||
return $values[$middle];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the timings to shards.json.
|
||||
*
|
||||
* @param array<string, float> $timings
|
||||
*/
|
||||
private function writeTimings(array $timings): void
|
||||
{
|
||||
$path = $this->shardsPath();
|
||||
|
||||
$directory = dirname($path);
|
||||
if (! is_dir($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
if (self::$knownTests !== null) {
|
||||
$knownSet = array_flip(self::$knownTests);
|
||||
$timings = array_intersect_key($timings, $knownSet);
|
||||
}
|
||||
|
||||
ksort($timings);
|
||||
|
||||
$canonical = self::$knownTests ?? array_keys($timings);
|
||||
sort($canonical);
|
||||
|
||||
file_put_contents($path, json_encode([
|
||||
'timings' => $timings,
|
||||
'checksum' => md5(implode("\n", $canonical)),
|
||||
'updated_at' => date('c'),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shard information.
|
||||
*
|
||||
* @return array{index: int, total: int}
|
||||
*/
|
||||
public static function getShard(InputInterface $input): array
|
||||
{
|
||||
if ($input->hasParameterOption('--'.self::SHARD_OPTION)) {
|
||||
$shard = $input->getParameterOption('--'.self::SHARD_OPTION);
|
||||
} else {
|
||||
$shard = null;
|
||||
}
|
||||
|
||||
if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) {
|
||||
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
|
||||
}
|
||||
|
||||
[$index, $total] = explode('/', $shard);
|
||||
|
||||
if (! is_numeric($index) || ! is_numeric($total)) {
|
||||
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
|
||||
}
|
||||
|
||||
if ($index <= 0 || $total <= 0 || $index > $total) {
|
||||
throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.');
|
||||
}
|
||||
|
||||
$index = (int) $index;
|
||||
$total = (int) $total;
|
||||
|
||||
return [
|
||||
'index' => $index,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Exceptions\InvalidOption;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
@ -15,21 +14,116 @@ final class Snapshot implements HandlesArguments
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
/**
|
||||
* Whether snapshots should be updated on this run.
|
||||
*/
|
||||
public static bool $updateSnapshots = false;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function handleArguments(array $arguments): array
|
||||
{
|
||||
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SNAPSHOTS') === true) {
|
||||
self::$updateSnapshots = true;
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--update-snapshots', $arguments)) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
if ($this->hasArgument('--parallel', $arguments)) {
|
||||
throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.');
|
||||
self::$updateSnapshots = true;
|
||||
|
||||
if ($this->isFullRun($arguments)) {
|
||||
TestSuite::getInstance()->snapshots->flush();
|
||||
}
|
||||
|
||||
TestSuite::getInstance()->snapshots->flush();
|
||||
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
|
||||
Parallel::setGlobal('UPDATE_SNAPSHOTS', true);
|
||||
}
|
||||
|
||||
return $this->popArgument('--update-snapshots', $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that take a value as the next argument (rather than via "=value").
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array FLAGS_WITH_VALUES = [
|
||||
'--filter',
|
||||
'--group',
|
||||
'--exclude-group',
|
||||
'--test-suffix',
|
||||
'--covers',
|
||||
'--uses',
|
||||
'--cache-directory',
|
||||
'--cache-result-file',
|
||||
'--configuration',
|
||||
'--colors',
|
||||
'--test-directory',
|
||||
'--bootstrap',
|
||||
'--order-by',
|
||||
'--random-order-seed',
|
||||
'--log-junit',
|
||||
'--log-teamcity',
|
||||
'--log-events-text',
|
||||
'--log-events-verbose-text',
|
||||
'--coverage-clover',
|
||||
'--coverage-cobertura',
|
||||
'--coverage-crap4j',
|
||||
'--coverage-html',
|
||||
'--coverage-php',
|
||||
'--coverage-text',
|
||||
'--coverage-xml',
|
||||
'--assignee',
|
||||
'--issue',
|
||||
'--ticket',
|
||||
'--pr',
|
||||
'--pull-request',
|
||||
'--retry',
|
||||
'--shard',
|
||||
'--repeat',
|
||||
];
|
||||
|
||||
/**
|
||||
* Determines whether the command targets the entire suite (no filter, no path).
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function isFullRun(array $arguments): bool
|
||||
{
|
||||
if ($this->hasArgument('--filter', $arguments)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tokens = array_slice($arguments, 1);
|
||||
$skipNext = false;
|
||||
|
||||
foreach ($tokens as $arg) {
|
||||
if ($skipNext) {
|
||||
$skipNext = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($arg[0] === '-') {
|
||||
if (in_array($arg, self::FLAGS_WITH_VALUES, true)) {
|
||||
$skipNext = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
865
src/Plugins/Tia.php
Normal file
865
src/Plugins/Tia.php
Normal file
@ -0,0 +1,865 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Test Impact Analysis (file-level, parallel-aware).
|
||||
*
|
||||
* Modes
|
||||
* -----
|
||||
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
||||
* full suite runs with PCOV / Xdebug capture per test; the resulting
|
||||
* `test → [source_file, …]` edges land in `.temp/tia.json`.
|
||||
* - **Replay** — graph valid. We diff the working tree against the recording
|
||||
* commit, intersect changed files with graph edges, and run only the
|
||||
* affected tests. Newly-added tests unknown to the graph are always
|
||||
* accepted (skipping them would be a correctness hazard).
|
||||
*
|
||||
* Parallel integration
|
||||
* --------------------
|
||||
* This plugin MUST run before `Pest\Plugins\Parallel` in the registered
|
||||
* plugin list — Parallel exits the process as soon as it sees `--parallel`,
|
||||
* so later plugins never get their turn. With the correct order:
|
||||
*
|
||||
* - **Parent, replay**: narrow the CLI args down to the affected test
|
||||
* files before Parallel hands them to paratest. Workers then only see
|
||||
* the narrowed file set and nothing special is required of them.
|
||||
* - **Parent, record**: flip a global recording flag (via
|
||||
* `Parallel::setGlobal`) so every spawned worker activates its own
|
||||
* coverage recorder. The parent does not itself record (paratest runs
|
||||
* tests in workers); instead we register an `AddsOutput` hook that
|
||||
* merges per-worker partial graphs after paratest finishes.
|
||||
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
|
||||
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
||||
* activate the `Recorder`, and flush the partial graph on `terminate()`
|
||||
* into `.temp/tia-worker-<TEST_TOKEN>.json`.
|
||||
* - **Worker, replay**: nothing to do; args already narrowed.
|
||||
*
|
||||
* Guardrails
|
||||
* ----------
|
||||
* - `--tia` combined with `--coverage` is refused: both paths drive the
|
||||
* same coverage driver and would corrupt each other's data.
|
||||
* - If no coverage driver is available during record, we skip gracefully;
|
||||
* the suite still runs normally.
|
||||
* - A stale recording SHA (rebase / force-push) triggers a rebuild.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
private const string OPTION = '--tia';
|
||||
|
||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
||||
|
||||
/**
|
||||
* TIA cache lives inside Pest's `.temp/` directory (same location as
|
||||
* PHPUnit's result cache). This directory is gitignored by default in
|
||||
* Pest's own `.gitignore`, so the graph is never committed.
|
||||
*/
|
||||
private const string TEMP_DIR = __DIR__
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp';
|
||||
|
||||
private const string CACHE_FILE = 'tia.json';
|
||||
|
||||
private const string AFFECTED_FILE = 'tia-affected.json';
|
||||
|
||||
private const string WORKER_PREFIX = 'tia-worker-';
|
||||
|
||||
/**
|
||||
* Global flag toggled by the parent process so workers know to record.
|
||||
*/
|
||||
private const string RECORDING_GLOBAL = 'TIA_RECORDING';
|
||||
|
||||
/**
|
||||
* Global flag that tells workers to install the TIA filter (replay mode).
|
||||
* Workers read the affected set from `.temp/tia-affected.json`.
|
||||
*/
|
||||
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||
|
||||
private bool $graphWritten = false;
|
||||
|
||||
private bool $replayRan = false;
|
||||
|
||||
/**
|
||||
* Counts cache hits during a replay run. Incremented each time
|
||||
* `getCachedResult()` returns a non-null status so the end-of-run
|
||||
* summary reflects what actually happened, not a graph-level estimate.
|
||||
*/
|
||||
private int $replayedCount = 0;
|
||||
|
||||
/**
|
||||
* Captured at replay setup so the end-of-run summary can report the
|
||||
* scope of the changes that drove the run.
|
||||
*/
|
||||
private int $changedFileCount = 0;
|
||||
|
||||
/**
|
||||
* Captured at replay setup — number of tests the graph flagged as
|
||||
* affected (i.e. should re-execute). May overshoot the actually-
|
||||
* executed count when the user narrows with a path filter.
|
||||
*/
|
||||
private int $affectedTestCount = 0;
|
||||
|
||||
/**
|
||||
* Holds the graph during replay so `beforeEach` can look up cached
|
||||
* results without re-loading from disk on every test.
|
||||
*/
|
||||
private ?Graph $replayGraph = null;
|
||||
|
||||
/**
|
||||
* Current git branch (or `HEAD` SHA when detached). Resolved once per
|
||||
* run so all graph accesses use the same branch key.
|
||||
*/
|
||||
private string $branch = 'main';
|
||||
|
||||
/**
|
||||
* Test files that are affected (should re-execute). Keyed by
|
||||
* project-relative path. Set during `enterReplayMode`.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $affectedFiles = [];
|
||||
|
||||
private static function tempDir(): string
|
||||
{
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
|
||||
if ($dir === '' || $dir === '.') {
|
||||
// .temp doesn't exist yet — create it.
|
||||
@mkdir(self::TEMP_DIR, 0755, true);
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private static function cachePath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function affectedPath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE;
|
||||
}
|
||||
|
||||
private static function workerPath(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerGlob(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
private readonly Recorder $recorder,
|
||||
private readonly WatchPatterns $watchPatterns,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the cached result for the given test, or `null` if the test
|
||||
* must run (affected, unknown, or no replay mode active).
|
||||
*/
|
||||
public function getCachedResult(string $filename, string $testId): ?TestStatus
|
||||
{
|
||||
if ($this->replayGraph === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve file to project-relative path.
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$real = @realpath($filename);
|
||||
$rel = $real !== false
|
||||
? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR)))
|
||||
: null;
|
||||
|
||||
// Affected files must re-execute.
|
||||
if ($rel !== null && isset($this->affectedFiles[$rel])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unknown files (not in graph) must execute — they're new.
|
||||
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Known + unaffected: return cached result if we have one for this
|
||||
// branch (falls back to main if branch is fresh).
|
||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||
|
||||
if ($result !== null) {
|
||||
$this->replayedCount++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function handleArguments(array $arguments): array
|
||||
{
|
||||
$isWorker = Parallel::isWorker();
|
||||
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||
|
||||
$enabled = $this->hasArgument(self::OPTION, $arguments);
|
||||
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
|
||||
|
||||
if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$arguments = $this->popArgument(self::OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::REBUILD_OPTION, $arguments);
|
||||
|
||||
if ($this->coverageReportActive()) {
|
||||
if (! $isWorker) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> `--coverage` is active — TIA disabled to avoid '.
|
||||
'conflicting with PHPUnit\'s own coverage collection.',
|
||||
);
|
||||
}
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
if ($isWorker) {
|
||||
return $this->handleWorker($arguments, $projectRoot, $recordingGlobal, $replayingGlobal);
|
||||
}
|
||||
|
||||
return $this->handleParent($arguments, $projectRoot, $forceRebuild);
|
||||
}
|
||||
|
||||
public function terminate(): void
|
||||
{
|
||||
if ($this->graphWritten) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->graphWritten = true;
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$perTest = $recorder->perTestFiles();
|
||||
|
||||
if ($perTest === []) {
|
||||
$recorder->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (Parallel::isWorker()) {
|
||||
$this->flushWorkerPartial($projectRoot, $perTest);
|
||||
$recorder->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-parallel record path: straight into the main cache.
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->replaceEdges($perTest);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
if (! $graph->save($cachePath)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
|
||||
$recorder->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files) at %s',
|
||||
count($perTest),
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
$recorder->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs after paratest finishes in the parent process. If we were
|
||||
* recording across workers, merge their partial graphs into the main
|
||||
* cache now.
|
||||
*/
|
||||
public function addOutput(int $exitCode): int
|
||||
{
|
||||
if (Parallel::isWorker()) {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
// After a successful replay run, advance the recorded SHA to HEAD
|
||||
// so the next run only diffs against what changed since NOW, not
|
||||
// since the original recording. Without this, re-running `--tia`
|
||||
// twice in a row would re-execute the same affected tests both
|
||||
// times even though nothing new changed.
|
||||
if ($this->replayRan) {
|
||||
$this->bumpRecordedSha();
|
||||
$this->emitReplaySummary();
|
||||
}
|
||||
|
||||
// Snapshot per-test results (status + message) from PHPUnit's result
|
||||
// cache into our graph so future replay runs can faithfully reproduce
|
||||
// pass/fail/skip/todo/incomplete for unaffected tests.
|
||||
$this->snapshotTestResults();
|
||||
|
||||
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$partials = $this->collectWorkerPartials($projectRoot);
|
||||
|
||||
if ($partials === []) {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
|
||||
$merged = [];
|
||||
|
||||
foreach ($partials as $partialPath) {
|
||||
$data = $this->readPartial($partialPath);
|
||||
|
||||
if ($data === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($data as $testFile => $sources) {
|
||||
if (! isset($merged[$testFile])) {
|
||||
$merged[$testFile] = [];
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$merged[$testFile][$source] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($partialPath);
|
||||
}
|
||||
|
||||
$finalised = [];
|
||||
|
||||
foreach ($merged as $testFile => $sourceSet) {
|
||||
$finalised[$testFile] = array_keys($sourceSet);
|
||||
}
|
||||
|
||||
$graph->replaceEdges($finalised);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
if (! $graph->save($cachePath)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s',
|
||||
count($finalised),
|
||||
count($partials),
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
|
||||
{
|
||||
// Initialise watch patterns (defaults + any user additions from
|
||||
// tests/Pest.php which has already been loaded by BootFiles at
|
||||
// this point).
|
||||
$this->watchPatterns->useDefaults($projectRoot);
|
||||
|
||||
// Resolve current branch once per run so every baseline lookup uses
|
||||
// the same key. Detached HEAD (or no git) falls back to `main` as
|
||||
// the implicit branch identity.
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
|
||||
|
||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> environment fingerprint changed — graph will be rebuilt.',
|
||||
);
|
||||
$graph = null;
|
||||
}
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$branchSha = $graph->recordedAtSha($this->branch);
|
||||
|
||||
if ($changedFiles->gitAvailable()
|
||||
&& $branchSha !== null
|
||||
&& $changedFiles->since($branchSha) === null) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.',
|
||||
);
|
||||
$graph = null;
|
||||
}
|
||||
}
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
return $this->enterReplayMode($graph, $projectRoot, $arguments);
|
||||
}
|
||||
|
||||
return $this->enterRecordMode($projectRoot, $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
|
||||
{
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
if ($replayingGlobal) {
|
||||
// Replay in a worker: load the graph and the affected set that
|
||||
// the parent persisted, then install the per-file filter so
|
||||
// whichever tests paratest happens to hand this worker are
|
||||
// accepted / rejected consistently with the series path.
|
||||
$this->installWorkerReplay($projectRoot);
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
if (! $recordingGlobal) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->driverAvailable()) {
|
||||
// Driver availability is per-process. If the driver is missing
|
||||
// here, silently skip — the parent has already warned during
|
||||
// its own boot.
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$recorder->activate();
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires worker-side replay. Mirrors the series path: sets `replayGraph`
|
||||
* + `affectedFiles` so the `BeforeEachable` hook in `beforeEach()` can
|
||||
* answer per-test. Unaffected tests replay their cached status (pass,
|
||||
* fail, skip, todo, incomplete) so the user sees the full suite report
|
||||
* in parallel runs exactly like in series.
|
||||
*/
|
||||
private function installWorkerReplay(string $projectRoot): void
|
||||
{
|
||||
$cachePath = self::cachePath();
|
||||
$affectedPath = self::affectedPath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($affectedPath);
|
||||
|
||||
if ($raw === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
foreach ($decoded as $rel) {
|
||||
if (is_string($rel)) {
|
||||
$affectedSet[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->replayGraph = $graph;
|
||||
$this->affectedFiles = $affectedSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function enterReplayMode(Graph $graph, string $projectRoot, array $arguments): array
|
||||
{
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
|
||||
if (! $changedFiles->gitAvailable()) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> git unavailable — running full suite.',
|
||||
);
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? [];
|
||||
|
||||
// Drop files whose content hash matches the last-run snapshot. This
|
||||
// is the "dirty but identical" filter: if a file is uncommitted but
|
||||
// its content hasn't moved since the last `--tia` invocation, its
|
||||
// dependents already re-ran last time and don't need re-running
|
||||
// again.
|
||||
$changed = $changedFiles->filterUnchangedSinceLastRun($changed, $graph->lastRunTree($this->branch));
|
||||
|
||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||
|
||||
$this->changedFileCount = count($changed);
|
||||
$this->affectedTestCount = count($affected);
|
||||
|
||||
$affectedSet = array_fill_keys($affected, true);
|
||||
|
||||
$this->replayRan = true;
|
||||
$this->replayGraph = $graph;
|
||||
$this->affectedFiles = $affectedSet;
|
||||
|
||||
if (! Parallel::isEnabled()) {
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
// Parallel: persist affected set so workers can install the filter.
|
||||
if (! $this->persistAffectedSet($projectRoot, $affected)) {
|
||||
$this->output->writeln(
|
||||
' <fg=red>TIA</> failed to persist affected set — running full suite.',
|
||||
);
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $affected Project-relative paths.
|
||||
*/
|
||||
private function persistAffectedSet(string $projectRoot, array $affected): bool
|
||||
{
|
||||
$path = self::affectedPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function enterRecordMode(string $projectRoot, array $arguments): array
|
||||
{
|
||||
if (Parallel::isEnabled()) {
|
||||
// Parent driving `--parallel`: workers will do the actual
|
||||
// recording. We only advertise the intent through a global.
|
||||
// Clean up any stale partial files from a previous interrupted
|
||||
// run so the merge step doesn't confuse itself.
|
||||
$this->purgeWorkerPartials($projectRoot);
|
||||
|
||||
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
|
||||
|
||||
$this->output->writeln(
|
||||
' <fg=cyan>TIA</> recording dependency graph in parallel (first run) — '.
|
||||
'subsequent `--tia` runs will only re-execute affected tests.',
|
||||
);
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->driverAvailable()) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> No coverage driver is available.',
|
||||
'',
|
||||
' TIA requires ext-pcov or Xdebug with coverage mode enabled to',
|
||||
' record the dependency graph. Install one and rerun with `--tia`.',
|
||||
'',
|
||||
]);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$recorder->activate();
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> recording dependency graph via %s (first run) — '.
|
||||
'subsequent `--tia` runs will only re-execute affected tests.',
|
||||
$recorder->driver(),
|
||||
));
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $perTest
|
||||
*/
|
||||
private function flushWorkerPartial(string $projectRoot, array $perTest): void
|
||||
{
|
||||
$token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? getmypid();
|
||||
// Defensive: token might arrive as int or string depending on paratest
|
||||
// version. Cast + filter to keep filenames sane.
|
||||
$token = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $token);
|
||||
|
||||
if ($token === '') {
|
||||
$token = (string) getmypid();
|
||||
}
|
||||
|
||||
$path = self::workerPath($token);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function collectWorkerPartials(string $projectRoot): array
|
||||
{
|
||||
$pattern = self::workerGlob();
|
||||
$matches = glob($pattern);
|
||||
|
||||
return $matches === false ? [] : $matches;
|
||||
}
|
||||
|
||||
private function purgeWorkerPartials(string $projectRoot): void
|
||||
{
|
||||
foreach ($this->collectWorkerPartials($projectRoot) as $path) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>|null
|
||||
*/
|
||||
private function readPartial(string $path): ?array
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $test => $sources) {
|
||||
if (! is_string($test)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($sources)) {
|
||||
continue;
|
||||
}
|
||||
$clean = [];
|
||||
|
||||
foreach ($sources as $source) {
|
||||
if (is_string($source)) {
|
||||
$clean[] = $source;
|
||||
}
|
||||
}
|
||||
|
||||
$out[$test] = $clean;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful replay, bump the graph's `recorded_at_sha` to the
|
||||
* current HEAD. This way the next `--tia` run diffs only against what
|
||||
* changed since THIS run, not since the original recording.
|
||||
*
|
||||
* The graph edges themselves are untouched — only the SHA marker moves.
|
||||
*/
|
||||
/**
|
||||
* After a successful replay, advance the baseline: bump `recorded_at_sha`
|
||||
* to the current HEAD (handles committed changes) and snapshot the
|
||||
* working tree's content hashes (handles uncommitted changes). Next run
|
||||
* compares against this baseline so identical files are skipped even if
|
||||
* git still reports them as modified.
|
||||
*/
|
||||
/**
|
||||
* Prints the post-run TIA summary. Runs after the test report so the
|
||||
* replayed count reflects what actually happened (cache hits counted
|
||||
* inside `getCachedResult`) rather than a graph-level estimate that
|
||||
* ignores any CLI path filter the user passed in.
|
||||
*/
|
||||
private function emitReplaySummary(): void
|
||||
{
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
|
||||
$this->changedFileCount,
|
||||
$this->affectedTestCount,
|
||||
$this->replayedCount,
|
||||
));
|
||||
}
|
||||
|
||||
private function bumpRecordedSha(): void
|
||||
{
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
if ($currentSha !== null) {
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
}
|
||||
|
||||
// Snapshot the working tree: hash every currently-modified file.
|
||||
// On next run, files still appearing as modified but whose hash
|
||||
// matches this snapshot are treated as unchanged.
|
||||
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
|
||||
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
|
||||
|
||||
$graph->save($cachePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges per-test status + message from the `ResultCollector` into the
|
||||
* TIA graph. Runs after every `--tia` invocation so the graph always has
|
||||
* fresh results for faithful replay (pass, fail, skip, todo, etc.).
|
||||
*/
|
||||
private function snapshotTestResults(): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
$results = $collector->all();
|
||||
|
||||
if ($results === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']);
|
||||
}
|
||||
|
||||
$graph->save($cachePath);
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
private function coverageReportActive(): bool
|
||||
{
|
||||
try {
|
||||
/** @var Coverage $coverage */
|
||||
$coverage = Container::getInstance()->get(Coverage::class);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $coverage->coverage === true;
|
||||
}
|
||||
}
|
||||
325
src/Plugins/Tia/ChangedFiles.php
Normal file
325
src/Plugins/Tia/ChangedFiles.php
Normal file
@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Detects files that changed between the last recorded TIA run and the
|
||||
* current working tree.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. If we have a `recordedAtSha`, `git diff <sha>..HEAD` captures committed
|
||||
* changes on top of the recording point.
|
||||
* 2. `git status --short` captures unstaged + staged + untracked changes on
|
||||
* top of that.
|
||||
*
|
||||
* We return relative paths to the project root. Deletions are included so the
|
||||
* caller can decide whether to invalidate: a deleted source file may still
|
||||
* appear in the graph and should mark its dependents as affected.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class ChangedFiles
|
||||
{
|
||||
public function __construct(private string $projectRoot) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push) — in
|
||||
* that case the graph should be rebuilt.
|
||||
*/
|
||||
/**
|
||||
* Removes files whose current content hash matches the snapshot from the
|
||||
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
|
||||
* that git still reports as modified but whose content is bit-identical
|
||||
* to the previous TIA invocation.
|
||||
*
|
||||
* @param array<int, string> $files project-relative paths.
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
||||
{
|
||||
if ($lastRunTree === []) {
|
||||
return $files;
|
||||
}
|
||||
|
||||
$remaining = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! isset($lastRunTree[$file])) {
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// File is absent now. If the snapshot recorded it as absent
|
||||
// too (sentinel ''), state is identical to last run — treat
|
||||
// as unchanged. Otherwise it was present last run and got
|
||||
// deleted since — that's a real change.
|
||||
if ($lastRunTree[$file] !== '') {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = @hash_file('xxh128', $absolute);
|
||||
|
||||
if ($hash === false || $hash !== $lastRunTree[$file]) {
|
||||
$remaining[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes content hashes for the given project-relative files. Used to
|
||||
* snapshot the working tree after a successful run so the next run can
|
||||
* detect which files are actually different.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
*/
|
||||
public function snapshotTree(array $files): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Record the deletion with an empty-string sentinel so the
|
||||
// next run recognises "still deleted" as unchanged rather
|
||||
// than re-flagging the file as a fresh change.
|
||||
$out[$file] = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = @hash_file('xxh128', $absolute);
|
||||
|
||||
if ($hash !== false) {
|
||||
$out[$file] = $hash;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||
* the recorded SHA is no longer reachable
|
||||
* from HEAD (rebase / force-push).
|
||||
*/
|
||||
public function since(?string $sha): ?array
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
if ($sha !== null && $sha !== '') {
|
||||
if (! $this->shaIsReachable($sha)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$files = array_merge($files, $this->diffSinceSha($sha));
|
||||
}
|
||||
|
||||
$files = array_merge($files, $this->workingTreeChanges());
|
||||
|
||||
// Normalise + dedupe, filtering out paths that can never belong to the
|
||||
// graph: vendor (caught by the fingerprint instead), cache dirs, and
|
||||
// anything starting with a dot we don't care about.
|
||||
$unique = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->shouldIgnore($file)) {
|
||||
continue;
|
||||
}
|
||||
$unique[$file] = true;
|
||||
}
|
||||
|
||||
return array_keys($unique);
|
||||
}
|
||||
|
||||
private function shouldIgnore(string $path): bool
|
||||
{
|
||||
static $prefixes = [
|
||||
'.pest/',
|
||||
'.phpunit.cache/',
|
||||
'.phpunit.result.cache',
|
||||
'vendor/',
|
||||
'node_modules/',
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($path, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function currentBranch(): ?string
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$branch = trim($process->getOutput());
|
||||
|
||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||
}
|
||||
|
||||
public function gitAvailable(): bool
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
return $process->isSuccessful();
|
||||
}
|
||||
|
||||
private function shaIsReachable(string $sha): bool
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
|
||||
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
|
||||
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
|
||||
// "unreachable" and force a rebuild.
|
||||
return $process->getExitCode() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 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()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->splitLines($process->getOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function workingTreeChanges(): array
|
||||
{
|
||||
// `-z` produces NUL-terminated records with no path quoting, so paths
|
||||
// that contain spaces, tabs, unicode or other special characters
|
||||
// are passed through verbatim. Without `-z`, git wraps such paths in
|
||||
// quotes with backslash escapes, which would corrupt our lookup keys.
|
||||
//
|
||||
// Record format: `XY <SP> <path> <NUL>` for most entries, and
|
||||
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
|
||||
// fields).
|
||||
$process = new Process(
|
||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// Renames/copies emit two records: the new path first, then the
|
||||
// original. Consume both.
|
||||
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
|
||||
{
|
||||
if (! $this->gitAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
42
src/Plugins/Tia/Configuration.php
Normal file
42
src/Plugins/Tia/Configuration.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* User-facing TIA configuration, returned by `pest()->tia()`.
|
||||
*
|
||||
* Usage in `tests/Pest.php`:
|
||||
*
|
||||
* pest()->tia()->watch([
|
||||
* 'resources/js/**\/*.tsx' => 'tests/Browser',
|
||||
* 'public/build/**\/*' => 'tests/Browser',
|
||||
* ]);
|
||||
*
|
||||
* Patterns are merged with the built-in defaults (config, routes, views,
|
||||
* frontend assets, migrations). Duplicate glob keys overwrite the default
|
||||
* mapping so users can redirect a pattern to a narrower directory.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* Adds watch-pattern → test-directory mappings that supplement (or
|
||||
* override) the built-in defaults.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
95
src/Plugins/Tia/Fingerprint.php
Normal file
95
src/Plugins/Tia/Fingerprint.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Captures environmental inputs that, when changed, make the TIA graph stale.
|
||||
*
|
||||
* Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change
|
||||
* what a test actually exercises, so the graph must be rebuilt in those cases.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
// Bump this whenever the set of inputs or the hash algorithm changes, so
|
||||
// older graphs are invalidated automatically.
|
||||
private const int SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public static function compute(string $projectRoot): array
|
||||
{
|
||||
return [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
'php' => PHP_VERSION,
|
||||
'pest' => self::readPestVersion($projectRoot),
|
||||
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
||||
// Pest's generated classes bake the code-generation logic in — if
|
||||
// TestCaseFactory changes (new attribute, different method
|
||||
// signature, etc.) every previously-recorded edge is stale.
|
||||
// Hashing the factory sources makes path-repo / dev-main installs
|
||||
// automatically rebuild their graphs when Pest itself is edited.
|
||||
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $a
|
||||
* @param array<string, mixed> $b
|
||||
*/
|
||||
public static function matches(array $a, array $b): bool
|
||||
{
|
||||
ksort($a);
|
||||
ksort($b);
|
||||
|
||||
return $a === $b;
|
||||
}
|
||||
|
||||
private static function hashIfExists(string $path): ?string
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hash = @hash_file('xxh128', $path);
|
||||
|
||||
return $hash === false ? null : $hash;
|
||||
}
|
||||
|
||||
private static function readPestVersion(string $projectRoot): string
|
||||
{
|
||||
$installed = $projectRoot.'/vendor/composer/installed.json';
|
||||
|
||||
if (! is_file($installed)) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($installed);
|
||||
|
||||
if ($raw === false) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
foreach ($data['packages'] as $package) {
|
||||
if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') {
|
||||
return (string) ($package['version'] ?? 'unknown');
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
500
src/Plugins/Tia/Graph.php
Normal file
500
src/Plugins/Tia/Graph.php
Normal file
@ -0,0 +1,500 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Support\Container;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
|
||||
/**
|
||||
* File-level Test Impact Analysis graph.
|
||||
*
|
||||
* Persists the mapping `test_file → set<source_file>` so that subsequent runs
|
||||
* can skip tests whose dependencies have not changed. Paths are stored relative
|
||||
* to the project root and source files are deduplicated via an index so that
|
||||
* the on-disk JSON stays compact for large suites.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Graph
|
||||
{
|
||||
/**
|
||||
* Relative path of each known source file, indexed by numeric id.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $files = [];
|
||||
|
||||
/**
|
||||
* Reverse lookup: source file → numeric id.
|
||||
*
|
||||
* @var array<string, int>
|
||||
*/
|
||||
private array $fileIds = [];
|
||||
|
||||
/**
|
||||
* Edges: test file (relative) → list of source file ids.
|
||||
*
|
||||
* @var array<string, array<int, int>>
|
||||
*/
|
||||
private array $edges = [];
|
||||
|
||||
/**
|
||||
* Environment fingerprint captured at record time.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $fingerprint = [];
|
||||
|
||||
/**
|
||||
* Per-branch baselines. Each branch independently tracks:
|
||||
* - `sha` — last HEAD at which `--tia` ran on this branch
|
||||
* - `tree` — content hashes of modified files at that point
|
||||
* - `results` — per-test status + message + time
|
||||
*
|
||||
* Graph edges (test → source) stay shared across branches because
|
||||
* structure doesn't change per branch. Only run-state is per-branch so
|
||||
* a failing test on one branch doesn't poison another branch's replay.
|
||||
*
|
||||
* @var array<string, array{
|
||||
* sha: ?string,
|
||||
* tree: array<string, string>,
|
||||
* results: array<string, array{status: int, message: string, time: float}>
|
||||
* }>
|
||||
*/
|
||||
private array $baselines = [];
|
||||
|
||||
/**
|
||||
* Canonicalised project root. Resolved through `realpath()` so paths
|
||||
* captured by coverage drivers (always real filesystem targets) match
|
||||
* regardless of whether the user's CWD is a symlink or has trailing
|
||||
* separators.
|
||||
*/
|
||||
private readonly string $projectRoot;
|
||||
|
||||
public function __construct(string $projectRoot)
|
||||
{
|
||||
$real = @realpath($projectRoot);
|
||||
|
||||
$this->projectRoot = $real !== false ? $real : $projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a test file depends on the given source file.
|
||||
*/
|
||||
public function link(string $testFile, string $sourceFile): void
|
||||
{
|
||||
$testRel = $this->relative($testFile);
|
||||
$sourceRel = $this->relative($sourceFile);
|
||||
|
||||
if ($sourceRel === null || $testRel === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! isset($this->fileIds[$sourceRel])) {
|
||||
$id = count($this->files);
|
||||
$this->files[$id] = $sourceRel;
|
||||
$this->fileIds[$sourceRel] = $id;
|
||||
}
|
||||
|
||||
$this->edges[$testRel][] = $this->fileIds[$sourceRel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of test files whose dependencies intersect $changedFiles.
|
||||
*
|
||||
* Two resolution paths:
|
||||
* 1. **Coverage edges** — test depends on a PHP source file that changed.
|
||||
* 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a
|
||||
* glob that maps to a test directory; every test under that directory
|
||||
* is affected.
|
||||
*
|
||||
* @param array<int, string> $changedFiles Absolute or relative paths.
|
||||
* @return array<int, string> Relative test file paths.
|
||||
*/
|
||||
public function affected(array $changedFiles): array
|
||||
{
|
||||
// Normalise all changed paths once.
|
||||
$normalised = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
$normalised[] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Coverage-edge lookup (PHP → PHP).
|
||||
$changedIds = [];
|
||||
$unknownSourceDirs = [];
|
||||
|
||||
foreach ($normalised as $rel) {
|
||||
if (isset($this->fileIds[$rel])) {
|
||||
$changedIds[$this->fileIds[$rel]] = true;
|
||||
} elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
||||
// Source PHP file unknown to the graph — might be a new file
|
||||
// that only exists on this branch (graph inherited from main).
|
||||
// Track its directory for the sibling heuristic (step 3).
|
||||
$unknownSourceDirs[dirname($rel)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$affectedSet = [];
|
||||
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
foreach ($ids as $id) {
|
||||
if (isset($changedIds[$id])) {
|
||||
$affectedSet[$testFile] = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Watch-pattern lookup (non-PHP assets → test directories).
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
|
||||
$dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised);
|
||||
$allTestFiles = array_keys($this->edges);
|
||||
|
||||
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
// 3. Sibling heuristic for unknown source files.
|
||||
//
|
||||
// When a PHP source file is unknown to the graph (no test depends on
|
||||
// it), it is either genuinely untested OR it was added on a branch
|
||||
// whose graph was inherited from another branch (e.g. main). In the
|
||||
// latter case the graph simply never saw the file.
|
||||
//
|
||||
// To avoid silent misses: find tests that already cover ANY file in
|
||||
// the same directory. If `app/Models/OrderItem.php` is unknown but
|
||||
// `app/Models/Order.php` is covered by `OrderTest`, run `OrderTest`
|
||||
// — it likely exercises sibling files in the same module.
|
||||
//
|
||||
// This over-runs slightly (sibling may be unrelated) but never
|
||||
// under-runs. And once the test executes, its coverage captures the
|
||||
// new file → graph self-heals for next run.
|
||||
if ($unknownSourceDirs !== []) {
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
if (isset($affectedSet[$testFile])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (! isset($this->files[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depDir = dirname($this->files[$id]);
|
||||
|
||||
if (isset($unknownSourceDirs[$depDir])) {
|
||||
$affectedSet[$testFile] = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given test file has any recorded dependencies.
|
||||
*/
|
||||
public function knowsTest(string $testFile): bool
|
||||
{
|
||||
$rel = $this->relative($testFile);
|
||||
|
||||
return $rel !== null && isset($this->edges[$rel]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> All project-relative test files the graph knows.
|
||||
*/
|
||||
public function allTestFiles(): array
|
||||
{
|
||||
return array_keys($this->edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int|string|null> $fingerprint
|
||||
*/
|
||||
public function setFingerprint(array $fingerprint): void
|
||||
{
|
||||
$this->fingerprint = $fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function fingerprint(): array
|
||||
{
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA the given branch last ran against, or falls back to
|
||||
* `$fallbackBranch` (typically `main`) when this branch has no baseline
|
||||
* yet. That way a freshly-created feature branch inherits main's
|
||||
* baseline on its first run.
|
||||
*/
|
||||
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
return $baseline['sha'];
|
||||
}
|
||||
|
||||
public function setRecordedAtSha(string $branch, ?string $sha): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['sha'] = $sha;
|
||||
}
|
||||
|
||||
public function setResult(string $branch, string $testId, int $status, string $message, float $time): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'][$testId] = [
|
||||
'status' => $status, 'message' => $message, 'time' => $time,
|
||||
];
|
||||
}
|
||||
|
||||
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
if (! isset($baseline['results'][$testId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$r = $baseline['results'][$testId];
|
||||
|
||||
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
|
||||
// each variant via its specific factory. Keeps the stored message
|
||||
// intact (important for skips/failures shown to the user).
|
||||
return match ($r['status']) {
|
||||
0 => TestStatus::success(),
|
||||
1 => TestStatus::skipped($r['message']),
|
||||
2 => TestStatus::incomplete($r['message']),
|
||||
3 => TestStatus::notice($r['message']),
|
||||
4 => TestStatus::deprecation($r['message']),
|
||||
5 => TestStatus::risky($r['message']),
|
||||
6 => TestStatus::warning($r['message']),
|
||||
7 => TestStatus::failure($r['message']),
|
||||
8 => TestStatus::error($r['message']),
|
||||
default => TestStatus::unknown(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $tree project-relative path → content hash
|
||||
*/
|
||||
public function setLastRunTree(string $branch, array $tree): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
|
||||
{
|
||||
return $this->baselineFor($branch, $fallbackBranch)['tree'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float}>}
|
||||
*/
|
||||
private function baselineFor(string $branch, string $fallbackBranch): array
|
||||
{
|
||||
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' => []];
|
||||
}
|
||||
|
||||
private function ensureBaseline(string $branch): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch])) {
|
||||
$this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces edges for the given test files. Used during a partial record
|
||||
* run so that existing edges for other tests are preserved.
|
||||
*
|
||||
* @param array<string, array<int, string>> $testToFiles
|
||||
*/
|
||||
public function replaceEdges(array $testToFiles): void
|
||||
{
|
||||
foreach ($testToFiles as $testFile => $sources) {
|
||||
$testRel = $this->relative($testFile);
|
||||
|
||||
if ($testRel === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->edges[$testRel] = [];
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$this->link($testFile, $source);
|
||||
}
|
||||
|
||||
// Deduplicate ids for this test.
|
||||
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
||||
* from keeping stale entries for deleted / renamed tests that would later
|
||||
* be flagged as affected and confuse PHPUnit's discovery.
|
||||
*/
|
||||
public function pruneMissingTests(): void
|
||||
{
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
foreach (array_keys($this->edges) as $testRel) {
|
||||
if (! is_file($root.$testRel)) {
|
||||
unset($this->edges[$testRel]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function load(string $projectRoot, string $path): ?self
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$graph = new self($projectRoot);
|
||||
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
|
||||
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
|
||||
$graph->fileIds = array_flip($graph->files);
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
|
||||
return $graph;
|
||||
}
|
||||
|
||||
public function save(string $path): bool
|
||||
{
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'schema' => 1,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'files' => $this->files,
|
||||
'edges' => $this->edges,
|
||||
'baselines' => $this->baselines,
|
||||
];
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a path to be relative to the project root; returns `null` for
|
||||
* paths we should ignore (outside the project, unknown, virtual, vendor).
|
||||
*
|
||||
* Accepts both absolute paths (from Xdebug/PCOV coverage) and
|
||||
* project-relative paths (from `git diff`) — we normalise without relying
|
||||
* on `realpath()` of relative paths because the current working directory
|
||||
* is not guaranteed to be the project root.
|
||||
*/
|
||||
private function relative(string $path): ?string
|
||||
{
|
||||
if ($path === '' || $path === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_contains($path, "eval()'d")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive
|
||||
|
||||
if ($isAbsolute) {
|
||||
$real = @realpath($path);
|
||||
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
}
|
||||
|
||||
if (! str_starts_with($real, $root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always normalise to forward slashes. Windows' native separator
|
||||
// would otherwise produce keys that never match paths reported
|
||||
// by `git` (which always uses forward slashes).
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
} else {
|
||||
// Normalise directory separators and strip any "./" prefix.
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
|
||||
|
||||
while (str_starts_with($relative, './')) {
|
||||
$relative = substr($relative, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor packages are pinned by composer.lock. Any upgrade bumps the
|
||||
// fingerprint and invalidates the graph wholesale, so there is no
|
||||
// reason to track individual vendor files — doing so inflates the
|
||||
// graph by orders of magnitude on Laravel-style projects.
|
||||
if (str_starts_with($relative, 'vendor/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $relative;
|
||||
}
|
||||
}
|
||||
229
src/Plugins/Tia/Recorder.php
Normal file
229
src/Plugins/Tia/Recorder.php
Normal file
@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Captures per-test file coverage using the PCOV driver.
|
||||
*
|
||||
* Acts as a singleton because PCOV has a single global collection state and
|
||||
* the recorder is wired into PHPUnit through two distinct subscribers
|
||||
* (`Prepared` / `Finished`) that must share context.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Recorder
|
||||
{
|
||||
/**
|
||||
* Test file currently being recorded, or `null` when idle.
|
||||
*/
|
||||
private ?string $currentTestFile = null;
|
||||
|
||||
/**
|
||||
* Aggregated map: absolute test file → set<absolute source file>.
|
||||
*
|
||||
* @var array<string, array<string, true>>
|
||||
*/
|
||||
private array $perTestFiles = [];
|
||||
|
||||
/**
|
||||
* Cached class → test file resolution.
|
||||
*
|
||||
* @var array<string, string|null>
|
||||
*/
|
||||
private array $classFileCache = [];
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
|
||||
private bool $driverAvailable = false;
|
||||
|
||||
private string $driver = 'none';
|
||||
|
||||
public function activate(): void
|
||||
{
|
||||
$this->active = true;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
}
|
||||
|
||||
public function driverAvailable(): bool
|
||||
{
|
||||
if (! $this->driverChecked) {
|
||||
if (function_exists('pcov\\start')) {
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage')) {
|
||||
// Xdebug is loaded. Probe whether coverage mode is active by
|
||||
// attempting a start — it emits E_WARNING when the mode is off.
|
||||
// We capture the warning via a temporary error handler.
|
||||
$probeOk = true;
|
||||
set_error_handler(static function () use (&$probeOk): bool {
|
||||
$probeOk = false;
|
||||
|
||||
return true;
|
||||
});
|
||||
\xdebug_start_code_coverage();
|
||||
restore_error_handler();
|
||||
|
||||
if ($probeOk) {
|
||||
\xdebug_stop_code_coverage(false);
|
||||
$this->driver = 'xdebug';
|
||||
$this->driverAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->driverChecked = true;
|
||||
}
|
||||
|
||||
return $this->driverAvailable;
|
||||
}
|
||||
|
||||
public function driver(): string
|
||||
{
|
||||
$this->driverAvailable();
|
||||
|
||||
return $this->driver;
|
||||
}
|
||||
|
||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $this->resolveTestFile($className, $fallbackFile);
|
||||
|
||||
if ($file === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->currentTestFile = $file;
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
\pcov\start();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Xdebug
|
||||
\xdebug_start_code_coverage();
|
||||
}
|
||||
|
||||
public function endTest(): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\stop();
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\inclusive);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
// `true` resets Xdebug's internal buffer so the next `start()`
|
||||
// does not accumulate earlier tests' coverage into the current
|
||||
// one — otherwise the graph becomes progressively polluted.
|
||||
\xdebug_stop_code_coverage(true);
|
||||
}
|
||||
|
||||
foreach (array_keys($data) as $sourceFile) {
|
||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||
}
|
||||
|
||||
$this->currentTestFile = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
||||
*/
|
||||
public function perTestFiles(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($this->perTestFiles as $testFile => $sources) {
|
||||
$out[$testFile] = array_keys($sources);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the file that *defines* the test class.
|
||||
*
|
||||
* Order of preference:
|
||||
* 1. Pest's generated `$__filename` static — the original `*.php` file
|
||||
* containing the `test()` calls (the eval'd class itself has no file).
|
||||
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
|
||||
* is intentionally more specific than `ReflectionMethod::getFileName()`
|
||||
* (which would return the *trait* file for methods brought in via
|
||||
* `uses SharedTestBehavior`).
|
||||
*/
|
||||
private function readPestFilename(string $className): ?string
|
||||
{
|
||||
if (! class_exists($className, false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
$property = $reflection->getProperty('__filename');
|
||||
|
||||
if ($property->isStatic()) {
|
||||
$value = $property->getValue();
|
||||
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$file = $reflection->getFileName();
|
||||
|
||||
return is_string($file) ? $file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all captured state. Useful for long-running hosts (daemons,
|
||||
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
|
||||
* — without this, coverage from run N would bleed into run N+1.
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentTestFile = null;
|
||||
$this->perTestFiles = [];
|
||||
$this->classFileCache = [];
|
||||
$this->active = false;
|
||||
}
|
||||
}
|
||||
127
src/Plugins/Tia/ResultCollector.php
Normal file
127
src/Plugins/Tia/ResultCollector.php
Normal file
@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Collects per-test status + message during the run so the graph can persist
|
||||
* them for faithful replay. PHPUnit's own result cache discards messages
|
||||
* during serialisation — this collector retains them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ResultCollector
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?float $startTime = null;
|
||||
|
||||
public function testPrepared(string $testId): void
|
||||
{
|
||||
$this->currentTestId = $testId;
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
|
||||
public function testPassed(): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(0, '');
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(7, $message);
|
||||
}
|
||||
|
||||
public function testErrored(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(8, $message);
|
||||
}
|
||||
|
||||
public function testSkipped(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(1, $message);
|
||||
}
|
||||
|
||||
public function testIncomplete(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(2, $message);
|
||||
}
|
||||
|
||||
public function testRisky(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(5, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->results = [];
|
||||
$this->currentTestId = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function record(int $status, string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$time = $this->startTime !== null
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 0.0;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'time' => $time,
|
||||
'assertions' => 0,
|
||||
];
|
||||
|
||||
$this->currentTestId = null;
|
||||
$this->startTime = null;
|
||||
}
|
||||
}
|
||||
119
src/Plugins/Tia/WatchDefaults/Browser.php
Normal file
119
src/Plugins/Tia/WatchDefaults/Browser.php
Normal file
@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Browser\Support\BrowserTestIdentifier;
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* Watch patterns for frontend assets that affect browser tests.
|
||||
*
|
||||
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to
|
||||
* auto-discover directories containing browser tests. Falls back to the
|
||||
* `tests/Browser` convention when the plugin is absent.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Browser implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
// Browser tests can exist in any PHP project. We only activate when
|
||||
// there is an actual `tests/Browser` directory OR pest-plugin-browser
|
||||
// is installed.
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
|
||||
}
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath);
|
||||
|
||||
$globs = [
|
||||
'resources/js/**/*.js',
|
||||
'resources/js/**/*.ts',
|
||||
'resources/js/**/*.tsx',
|
||||
'resources/js/**/*.jsx',
|
||||
'resources/js/**/*.vue',
|
||||
'resources/js/**/*.svelte',
|
||||
'resources/css/**/*.css',
|
||||
'resources/css/**/*.scss',
|
||||
'resources/css/**/*.less',
|
||||
// Vite / Webpack build output that browser tests may consume.
|
||||
'public/build/**/*.js',
|
||||
'public/build/**/*.css',
|
||||
];
|
||||
|
||||
$patterns = [];
|
||||
|
||||
foreach ($globs as $glob) {
|
||||
$patterns[$glob] = $browserDirs;
|
||||
}
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function detectBrowserTestDirs(string $projectRoot, string $testPath): array
|
||||
{
|
||||
$dirs = [];
|
||||
|
||||
$candidate = $testPath.'/Browser';
|
||||
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
||||
$dirs[] = $candidate;
|
||||
}
|
||||
|
||||
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
||||
// is installed to find tests using `visit()` outside the conventional
|
||||
// Browser/ folder.
|
||||
if (class_exists(BrowserTestIdentifier::class)) {
|
||||
$repo = TestSuite::getInstance()->tests;
|
||||
|
||||
foreach ($repo->getFilenames() as $filename) {
|
||||
$factory = $repo->get($filename);
|
||||
|
||||
if (! $factory instanceof TestCaseFactory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($factory->methods as $method) {
|
||||
if (BrowserTestIdentifier::isBrowserTest($method)) {
|
||||
$rel = $this->fileRelative($projectRoot, $filename);
|
||||
|
||||
if ($rel !== null) {
|
||||
$dirs[] = dirname($rel);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($dirs === [] ? [$testPath] : $dirs));
|
||||
}
|
||||
|
||||
private function fileRelative(string $projectRoot, string $path): ?string
|
||||
{
|
||||
$real = @realpath($path);
|
||||
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
}
|
||||
|
||||
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
if (! str_starts_with($real, $root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
}
|
||||
}
|
||||
53
src/Plugins/Tia/WatchDefaults/Inertia.php
Normal file
53
src/Plugins/Tia/WatchDefaults/Inertia.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
|
||||
/**
|
||||
* Watch patterns for Inertia.js projects (Laravel or otherwise).
|
||||
*
|
||||
* Inertia bridges PHP controllers with JS/TS page components. A change to
|
||||
* a React / Vue / Svelte page can break assertions in browser tests or
|
||||
* Inertia-specific feature tests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Inertia implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& (InstalledVersions::isInstalled('inertiajs/inertia-laravel')
|
||||
|| InstalledVersions::isInstalled('rompetomp/inertia-bundle'));
|
||||
}
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser')
|
||||
? $testPath.'/Browser'
|
||||
: $testPath;
|
||||
|
||||
return [
|
||||
// Inertia page components (React / Vue / Svelte).
|
||||
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
|
||||
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
|
||||
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
|
||||
'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir],
|
||||
|
||||
// Shared layouts / components consumed by pages.
|
||||
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
||||
'resources/js/Layouts/**/*.tsx' => [$browserDir],
|
||||
'resources/js/Components/**/*.vue' => [$browserDir],
|
||||
'resources/js/Components/**/*.tsx' => [$browserDir],
|
||||
|
||||
// SSR entry point.
|
||||
'resources/js/ssr.js' => [$browserDir],
|
||||
'resources/js/ssr.ts' => [$browserDir],
|
||||
'resources/js/app.js' => [$browserDir],
|
||||
'resources/js/app.ts' => [$browserDir],
|
||||
];
|
||||
}
|
||||
}
|
||||
81
src/Plugins/Tia/WatchDefaults/Laravel.php
Normal file
81
src/Plugins/Tia/WatchDefaults/Laravel.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
|
||||
/**
|
||||
* Watch patterns for Laravel projects.
|
||||
*
|
||||
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
|
||||
* `Prepared` event where TIA's coverage window opens). That means PHP files
|
||||
* loaded during boot — config, routes, service providers, migrations — are
|
||||
* invisible to the coverage driver. Watch patterns are the only way to
|
||||
* track them.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Laravel implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('laravel/framework');
|
||||
}
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
$featurePath = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Feature')
|
||||
? $testPath.'/Feature'
|
||||
: $testPath;
|
||||
|
||||
return [
|
||||
// Config — loaded during app boot (setUp), invisible to coverage.
|
||||
// Affects both Feature and Unit: Pest.php commonly binds fakes
|
||||
// and seeds DB based on config values.
|
||||
'config/*.php' => [$testPath],
|
||||
'config/**/*.php' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
||||
'routes/*.php' => [$featurePath],
|
||||
'routes/**/*.php' => [$featurePath],
|
||||
|
||||
// Service providers / bootstrap — loaded during boot, affect
|
||||
// bindings, middleware, event listeners, scheduled tasks.
|
||||
'bootstrap/app.php' => [$testPath],
|
||||
'bootstrap/providers.php' => [$testPath],
|
||||
|
||||
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
|
||||
// setUp. Schema changes can break any test that touches DB.
|
||||
'database/migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Seeders — often run globally via Pest.php beforeEach.
|
||||
'database/seeders/**/*.php' => [$testPath],
|
||||
|
||||
// Factories — loaded lazily but still PHP that coverage may miss
|
||||
// if the factory file was already autoloaded before Prepared.
|
||||
'database/factories/**/*.php' => [$testPath],
|
||||
|
||||
// Blade templates — compiled to cache, source file not executed.
|
||||
'resources/views/**/*.blade.php' => [$featurePath],
|
||||
|
||||
// Translations — JSON translations read via file_get_contents,
|
||||
// PHP translations loaded via include (but during boot).
|
||||
'lang/**/*.php' => [$featurePath],
|
||||
'lang/**/*.json' => [$featurePath],
|
||||
'resources/lang/**/*.php' => [$featurePath],
|
||||
'resources/lang/**/*.json' => [$featurePath],
|
||||
|
||||
// Build tool config — affects compiled assets consumed by
|
||||
// browser and Inertia tests.
|
||||
'vite.config.js' => [$featurePath],
|
||||
'vite.config.ts' => [$featurePath],
|
||||
'webpack.mix.js' => [$featurePath],
|
||||
'tailwind.config.js' => [$featurePath],
|
||||
'tailwind.config.ts' => [$featurePath],
|
||||
'postcss.config.js' => [$featurePath],
|
||||
];
|
||||
}
|
||||
}
|
||||
38
src/Plugins/Tia/WatchDefaults/Livewire.php
Normal file
38
src/Plugins/Tia/WatchDefaults/Livewire.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
|
||||
/**
|
||||
* Watch patterns for projects using Livewire.
|
||||
*
|
||||
* Livewire components pair a PHP class with a Blade view. A view change can
|
||||
* break rendering or assertions in feature / browser tests even though the
|
||||
* PHP side is untouched.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Livewire implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('livewire/livewire');
|
||||
}
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
// Livewire views live alongside Blade views or in a dedicated dir.
|
||||
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
||||
'resources/views/components/**/*.blade.php' => [$testPath],
|
||||
|
||||
// Livewire JS interop / Alpine plugins.
|
||||
'resources/js/**/*.js' => [$testPath],
|
||||
'resources/js/**/*.ts' => [$testPath],
|
||||
];
|
||||
}
|
||||
}
|
||||
53
src/Plugins/Tia/WatchDefaults/Php.php
Normal file
53
src/Plugins/Tia/WatchDefaults/Php.php
Normal file
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
/**
|
||||
* Baseline watch patterns for any PHP project.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Php implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
// NOTE: composer.json / composer.lock changes are caught by the
|
||||
// fingerprint (which hashes composer.lock). PHP files are tracked by
|
||||
// the coverage driver. Only non-PHP, non-fingerprinted files that
|
||||
// can silently alter test behaviour belong here.
|
||||
|
||||
return [
|
||||
// Environment files — can change DB drivers, feature flags,
|
||||
// queue connections, etc. Not PHP, not fingerprinted.
|
||||
'.env' => [$testPath],
|
||||
'.env.testing' => [$testPath],
|
||||
|
||||
// Docker / CI — can affect integration test infrastructure.
|
||||
'docker-compose.yml' => [$testPath],
|
||||
'docker-compose.yaml' => [$testPath],
|
||||
|
||||
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
|
||||
// phpunit.xml.dist and other XML overrides are not individually
|
||||
// tracked by the coverage driver.
|
||||
'phpunit.xml.dist' => [$testPath],
|
||||
|
||||
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
||||
// assertions. A fixture change can flip a test result.
|
||||
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.csv' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.xml' => [$testPath],
|
||||
$testPath.'/Fixtures/**/*.txt' => [$testPath],
|
||||
|
||||
// Pest snapshots — external edits to snapshot files invalidate
|
||||
// snapshot assertions.
|
||||
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
|
||||
];
|
||||
}
|
||||
}
|
||||
75
src/Plugins/Tia/WatchDefaults/Symfony.php
Normal file
75
src/Plugins/Tia/WatchDefaults/Symfony.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
|
||||
/**
|
||||
* Watch patterns for Symfony projects.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Symfony implements WatchDefault
|
||||
{
|
||||
public function applicable(): bool
|
||||
{
|
||||
return class_exists(InstalledVersions::class)
|
||||
&& InstalledVersions::isInstalled('symfony/framework-bundle');
|
||||
}
|
||||
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
// Symfony boots the kernel in setUp() (before the coverage window).
|
||||
// PHP config, routes, kernel, and migrations are loaded during boot
|
||||
// and invisible to the coverage driver. Same reasoning as Laravel.
|
||||
|
||||
return [
|
||||
// Config — YAML, XML, and PHP. All loaded during kernel boot.
|
||||
'config/*.yaml' => [$testPath],
|
||||
'config/*.yml' => [$testPath],
|
||||
'config/*.php' => [$testPath],
|
||||
'config/*.xml' => [$testPath],
|
||||
'config/**/*.yaml' => [$testPath],
|
||||
'config/**/*.yml' => [$testPath],
|
||||
'config/**/*.php' => [$testPath],
|
||||
'config/**/*.xml' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot.
|
||||
'config/routes/*.yaml' => [$testPath],
|
||||
'config/routes/*.php' => [$testPath],
|
||||
'config/routes/*.xml' => [$testPath],
|
||||
'config/routes/**/*.yaml' => [$testPath],
|
||||
|
||||
// Kernel / bootstrap — loaded during boot.
|
||||
'src/Kernel.php' => [$testPath],
|
||||
|
||||
// Migrations — run during setUp (before coverage window).
|
||||
'migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Twig templates — compiled, source not PHP-executed.
|
||||
'templates/**/*.html.twig' => [$testPath],
|
||||
'templates/**/*.twig' => [$testPath],
|
||||
|
||||
// Translations (YAML / XLF / XLIFF).
|
||||
'translations/**/*.yaml' => [$testPath],
|
||||
'translations/**/*.yml' => [$testPath],
|
||||
'translations/**/*.xlf' => [$testPath],
|
||||
'translations/**/*.xliff' => [$testPath],
|
||||
|
||||
// Doctrine XML/YAML mappings.
|
||||
'config/doctrine/**/*.xml' => [$testPath],
|
||||
'config/doctrine/**/*.yaml' => [$testPath],
|
||||
|
||||
// Webpack Encore / asset-mapper config + frontend sources.
|
||||
'webpack.config.js' => [$testPath],
|
||||
'importmap.php' => [$testPath],
|
||||
'assets/**/*.js' => [$testPath],
|
||||
'assets/**/*.ts' => [$testPath],
|
||||
'assets/**/*.vue' => [$testPath],
|
||||
'assets/**/*.css' => [$testPath],
|
||||
'assets/**/*.scss' => [$testPath],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
src/Plugins/Tia/WatchDefaults/WatchDefault.php
Normal file
28
src/Plugins/Tia/WatchDefaults/WatchDefault.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
/**
|
||||
* A set of file-watch patterns that apply when a particular framework,
|
||||
* library or project layout is detected.
|
||||
*
|
||||
* Each implementation probes for the presence of the tool it covers
|
||||
* (`applicable`) and returns glob → test-directory mappings (`defaults`)
|
||||
* that are merged into `WatchPatterns`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface WatchDefault
|
||||
{
|
||||
/**
|
||||
* Whether this default set applies to the current project.
|
||||
*/
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> glob → list of project-relative test dirs
|
||||
*/
|
||||
public function defaults(string $projectRoot, string $testPath): array;
|
||||
}
|
||||
188
src/Plugins/Tia/WatchPatterns.php
Normal file
188
src/Plugins/Tia/WatchPatterns.php
Normal file
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* Maps non-PHP file globs to the test directories they should invalidate.
|
||||
*
|
||||
* Coverage drivers only see `.php` files. Frontend assets, config files,
|
||||
* Blade templates, routes and environment files are invisible to the graph.
|
||||
* Watch patterns bridge the gap: when a changed file matches a glob, every
|
||||
* test under the associated directory is marked as affected.
|
||||
*
|
||||
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
|
||||
* each implementation probes the current project and contributes patterns
|
||||
* when applicable. Users extend via `pest()->tia()->watch(…)`.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WatchPatterns
|
||||
{
|
||||
/**
|
||||
* All known default providers, in evaluation order.
|
||||
*
|
||||
* @var array<int, class-string<WatchDefault>>
|
||||
*/
|
||||
private const array DEFAULTS = [
|
||||
WatchDefaults\Php::class,
|
||||
WatchDefaults\Laravel::class,
|
||||
WatchDefaults\Symfony::class,
|
||||
WatchDefaults\Livewire::class,
|
||||
WatchDefaults\Inertia::class,
|
||||
WatchDefaults\Browser::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array<int, string>> glob → list of project-relative test dirs
|
||||
*/
|
||||
private array $patterns = [];
|
||||
|
||||
/**
|
||||
* Probes every registered `WatchDefault` and merges the patterns of
|
||||
* those that apply. Called once during Tia plugin boot, after BootFiles
|
||||
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
|
||||
* calls are already in `$this->patterns`).
|
||||
*/
|
||||
public function useDefaults(string $projectRoot): void
|
||||
{
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
|
||||
foreach (self::DEFAULTS as $class) {
|
||||
$default = new $class;
|
||||
|
||||
if (! $default->applicable()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
|
||||
$this->patterns[$glob] = array_values(array_unique(
|
||||
array_merge($this->patterns[$glob] ?? [], $dirs),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds user-defined patterns. Merges with existing entries so a single
|
||||
* glob can map to multiple directories.
|
||||
*
|
||||
* @param array<string, string> $patterns glob → project-relative test dir
|
||||
*/
|
||||
public function add(array $patterns): void
|
||||
{
|
||||
foreach ($patterns as $glob => $dir) {
|
||||
$this->patterns[$glob] = array_values(array_unique(
|
||||
array_merge($this->patterns[$glob] ?? [], [$dir]),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all test directories whose watch patterns match at least one of
|
||||
* the given changed files.
|
||||
*
|
||||
* @param string $projectRoot Absolute path.
|
||||
* @param array<int, string> $changedFiles Project-relative paths.
|
||||
* @return array<int, string> Project-relative test directories.
|
||||
*/
|
||||
public function matchedDirectories(string $projectRoot, array $changedFiles): array
|
||||
{
|
||||
if ($this->patterns === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
foreach ($this->patterns as $glob => $dirs) {
|
||||
if ($this->globMatches($glob, $file)) {
|
||||
foreach ($dirs as $dir) {
|
||||
$matched[$dir] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($matched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the affected directories, returns every test file in the graph
|
||||
* that lives under one of those directories.
|
||||
*
|
||||
* @param array<int, string> $directories Project-relative dirs.
|
||||
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function testsUnderDirectories(array $directories, array $allTestFiles): array
|
||||
{
|
||||
if ($directories === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$affected = [];
|
||||
|
||||
foreach ($allTestFiles as $testFile) {
|
||||
foreach ($directories as $dir) {
|
||||
$prefix = rtrim($dir, '/').'/';
|
||||
|
||||
if (str_starts_with($testFile, $prefix)) {
|
||||
$affected[] = $testFile;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $affected;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->patterns = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a project-relative file against a glob pattern.
|
||||
*
|
||||
* Supports `*` (single segment), `**` (any depth) and `?`.
|
||||
*/
|
||||
private function globMatches(string $pattern, string $file): bool
|
||||
{
|
||||
$pattern = str_replace('\\', '/', $pattern);
|
||||
$file = str_replace('\\', '/', $file);
|
||||
|
||||
$regex = '';
|
||||
$len = strlen($pattern);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
$c = $pattern[$i];
|
||||
|
||||
if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
|
||||
$regex .= '.*';
|
||||
$i += 2;
|
||||
|
||||
if (isset($pattern[$i]) && $pattern[$i] === '/') {
|
||||
$i++;
|
||||
}
|
||||
} elseif ($c === '*') {
|
||||
$regex .= '[^/]*';
|
||||
$i++;
|
||||
} elseif ($c === '?') {
|
||||
$regex .= '[^/]';
|
||||
$i++;
|
||||
} else {
|
||||
$regex .= preg_quote($c, '#');
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
return (bool) preg_match('#^'.$regex.'$#', $file);
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ final class Verbose implements HandlesArguments
|
||||
/**
|
||||
* The list of verbosity levels.
|
||||
*/
|
||||
private const VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
|
||||
private const array VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
|
||||
@ -19,7 +19,7 @@ use function sprintf;
|
||||
*/
|
||||
final class DatasetsRepository
|
||||
{
|
||||
private const SEPARATOR = '>>';
|
||||
private const string SEPARATOR = '>>';
|
||||
|
||||
/**
|
||||
* Holds the datasets.
|
||||
@ -67,11 +67,11 @@ final class DatasetsRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Closure|array<int|string, mixed>
|
||||
* @return array<int|string, mixed>
|
||||
*
|
||||
* @throws ShouldNotHappen
|
||||
*/
|
||||
public static function get(string $filename, string $description): Closure|array
|
||||
public static function get(string $filename, string $description): array // @phpstan-ignore-line
|
||||
{
|
||||
$dataset = self::$withs[$filename.self::SEPARATOR.$description];
|
||||
|
||||
@ -110,7 +110,6 @@ final class DatasetsRepository
|
||||
foreach ($datasetCombination as $datasetCombinationElement) {
|
||||
$partialDescriptions[] = $datasetCombinationElement['label'];
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$values = array_merge($values, $datasetCombinationElement['values']);
|
||||
}
|
||||
|
||||
@ -192,6 +191,7 @@ final class DatasetsRepository
|
||||
return str_starts_with($currentTestFile, $datasetScope);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
/** @var string|null $closestScopeDatasetKey */
|
||||
$closestScopeDatasetKey = array_reduce(
|
||||
array_keys($matchingDatasets),
|
||||
fn (string|int|null $keyA, string|int|null $keyB): string|int|null => $keyA !== null && strlen((string) $keyA) > strlen((string) $keyB) ? $keyA : $keyB
|
||||
@ -221,7 +221,6 @@ final class DatasetsRepository
|
||||
$result = $tmp;
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
@ -19,8 +19,9 @@ final class SnapshotRepository
|
||||
* Creates a snapshot repository instance.
|
||||
*/
|
||||
public function __construct(
|
||||
readonly private string $testsPath,
|
||||
readonly private string $snapshotsPath,
|
||||
private readonly string $rootPath,
|
||||
private readonly string $testsPath,
|
||||
private readonly string $snapshotsPath,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -58,8 +59,10 @@ final class SnapshotRepository
|
||||
{
|
||||
$snapshotFilename = $this->getSnapshotFilename();
|
||||
|
||||
if (! file_exists(dirname($snapshotFilename))) {
|
||||
mkdir(dirname($snapshotFilename), 0755, true);
|
||||
$directory = dirname($snapshotFilename);
|
||||
|
||||
if (! is_dir($directory)) {
|
||||
@mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($snapshotFilename, $snapshot);
|
||||
@ -103,7 +106,19 @@ final class SnapshotRepository
|
||||
*/
|
||||
private function getSnapshotFilename(): string
|
||||
{
|
||||
$relativePath = str_replace($this->testsPath, '', TestSuite::getInstance()->getFilename());
|
||||
$testFile = TestSuite::getInstance()->getFilename();
|
||||
|
||||
if (str_starts_with($testFile, $this->testsPath)) {
|
||||
// if the test file is in the tests directory
|
||||
$startPath = $this->testsPath;
|
||||
} else {
|
||||
// if the test file is in the app, src, etc. directory
|
||||
$startPath = $this->rootPath;
|
||||
}
|
||||
|
||||
// relative path: we use substr() and not str_replace() to remove the start path
|
||||
// for instance, if the $startPath is /app/ and the $testFile is /app/app/tests/Unit/ExampleTest.php, we should only remove the first /app/ from the path
|
||||
$relativePath = substr($testFile, strlen($startPath));
|
||||
|
||||
// remove extension from filename
|
||||
$relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.'));
|
||||
|
||||
@ -113,6 +113,16 @@ final class TestRepository
|
||||
$this->testCaseMethodFilters[] = $filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the class and traits configured for the given directory path.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getUsesForPath(string $path): array
|
||||
{
|
||||
return $this->uses[$path][0] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the test case factory from the given filename.
|
||||
*/
|
||||
|
||||
@ -4,20 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\Configuration;
|
||||
use PHPUnit\TextUI\ShellExitCodeCalculator;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Result
|
||||
{
|
||||
private const SUCCESS_EXIT = 0;
|
||||
|
||||
private const FAILURE_EXIT = 1;
|
||||
|
||||
private const EXCEPTION_EXIT = 2;
|
||||
private const int SUCCESS_EXIT = 0;
|
||||
|
||||
/**
|
||||
* If the exit code is different from 0.
|
||||
@ -40,44 +36,8 @@ final class Result
|
||||
*/
|
||||
public static function exitCode(Configuration $configuration, TestResult $result): int
|
||||
{
|
||||
if ($result->wasSuccessfulIgnoringPhpunitWarnings()) {
|
||||
if ($configuration->failOnWarning()) {
|
||||
$warnings = $result->numberOfTestsWithTestTriggeredPhpunitWarningEvents()
|
||||
+ count($result->warnings())
|
||||
+ count($result->phpWarnings());
|
||||
$shell = new ShellExitCodeCalculator;
|
||||
|
||||
if ($warnings > 0) {
|
||||
return self::FAILURE_EXIT;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $result->hasTestTriggeredPhpunitWarningEvents()) {
|
||||
return self::SUCCESS_EXIT;
|
||||
}
|
||||
}
|
||||
|
||||
if ($configuration->failOnEmptyTestSuite() && ResultReflection::numberOfTests($result) === 0) {
|
||||
return self::FAILURE_EXIT;
|
||||
}
|
||||
|
||||
if ($result->wasSuccessfulIgnoringPhpunitWarnings()) {
|
||||
if ($configuration->failOnRisky() && $result->hasTestConsideredRiskyEvents()) {
|
||||
$returnCode = self::FAILURE_EXIT;
|
||||
}
|
||||
|
||||
if ($configuration->failOnIncomplete() && $result->hasTestMarkedIncompleteEvents()) {
|
||||
$returnCode = self::FAILURE_EXIT;
|
||||
}
|
||||
|
||||
if ($configuration->failOnSkipped() && $result->hasTestSkippedEvents()) {
|
||||
$returnCode = self::FAILURE_EXIT;
|
||||
}
|
||||
}
|
||||
|
||||
if ($result->hasTestErroredEvents()) {
|
||||
return self::EXCEPTION_EXIT;
|
||||
}
|
||||
|
||||
return self::FAILURE_EXIT;
|
||||
return $shell->calculate($configuration, $result);
|
||||
}
|
||||
}
|
||||
|
||||
39
src/Runner/Filter/EnsureTestCaseIsInitiatedFilter.php
Normal file
39
src/Runner/Filter/EnsureTestCaseIsInitiatedFilter.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Runner\Filter;
|
||||
|
||||
use Pest\Contracts\HasPrintableTestCaseName;
|
||||
use PHPUnit\Framework\Test;
|
||||
use RecursiveFilterIterator;
|
||||
use RecursiveIterator;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTestCaseIsInitiatedFilter extends RecursiveFilterIterator
|
||||
{
|
||||
/**
|
||||
* @param RecursiveIterator<int, Test> $iterator
|
||||
*/
|
||||
public function __construct(RecursiveIterator $iterator)
|
||||
{
|
||||
parent::__construct($iterator);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function accept(): bool
|
||||
{
|
||||
$test = $this->getInnerIterator()->current();
|
||||
|
||||
if ($test instanceof HasPrintableTestCaseName) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
$test->__initializeTestCase();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -23,19 +23,17 @@ final class EnsureIgnorableTestCasesAreIgnored implements StartedSubscriber
|
||||
{
|
||||
$reflection = new ReflectionClass(Facade::class);
|
||||
$property = $reflection->getProperty('collector');
|
||||
$property->setAccessible(true);
|
||||
$collector = $property->getValue();
|
||||
|
||||
assert($collector instanceof Collector);
|
||||
|
||||
$reflection = new ReflectionClass($collector);
|
||||
$property = $reflection->getProperty('testRunnerTriggeredWarningEvents');
|
||||
$property->setAccessible(true);
|
||||
|
||||
/** @var array<int, WarningTriggered> $testRunnerTriggeredWarningEvents */
|
||||
$testRunnerTriggeredWarningEvents = $property->getValue($collector);
|
||||
|
||||
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => $event->message() !== 'No tests found in class "Pest\TestCases\IgnorableTestCase".'));
|
||||
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => str_contains($event->message(), 'No tests found in class') === false));
|
||||
|
||||
$property->setValue($collector, $testRunnerTriggeredWarningEvents);
|
||||
}
|
||||
|
||||
22
src/Subscribers/EnsureShardTimingFinished.php
Normal file
22
src/Subscribers/EnsureShardTimingFinished.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use PHPUnit\Event\TestSuite\Finished;
|
||||
use PHPUnit\Event\TestSuite\FinishedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureShardTimingFinished implements FinishedSubscriber
|
||||
{
|
||||
/**
|
||||
* Runs the subscriber.
|
||||
*/
|
||||
public function notify(Finished $event): void
|
||||
{
|
||||
EnsureShardTimingsAreCollected::finished($event);
|
||||
}
|
||||
}
|
||||
22
src/Subscribers/EnsureShardTimingStarted.php
Normal file
22
src/Subscribers/EnsureShardTimingStarted.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use PHPUnit\Event\TestSuite\Started;
|
||||
use PHPUnit\Event\TestSuite\StartedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureShardTimingStarted implements StartedSubscriber
|
||||
{
|
||||
/**
|
||||
* Runs the subscriber.
|
||||
*/
|
||||
public function notify(Started $event): void
|
||||
{
|
||||
EnsureShardTimingsAreCollected::started($event);
|
||||
}
|
||||
}
|
||||
75
src/Subscribers/EnsureShardTimingsAreCollected.php
Normal file
75
src/Subscribers/EnsureShardTimingsAreCollected.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\TestSuite\Finished;
|
||||
use PHPUnit\Event\TestSuite\Started;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureShardTimingsAreCollected
|
||||
{
|
||||
/**
|
||||
* The start times for each test class.
|
||||
*
|
||||
* @var array<string, HRTime>
|
||||
*/
|
||||
private static array $startTimes = [];
|
||||
|
||||
/**
|
||||
* The collected timings for each test class.
|
||||
*
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private static array $timings = [];
|
||||
|
||||
/**
|
||||
* Records the start time for a test suite.
|
||||
*/
|
||||
public static function started(Started $event): void
|
||||
{
|
||||
if (! $event->testSuite()->isForTestClass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
|
||||
|
||||
if (is_string($name)) {
|
||||
self::$startTimes[$name] = $event->telemetryInfo()->time();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the duration for a test suite.
|
||||
*/
|
||||
public static function finished(Finished $event): void
|
||||
{
|
||||
if (! $event->testSuite()->isForTestClass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
|
||||
|
||||
if (! is_string($name) || ! isset(self::$startTimes[$name])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$duration = $event->telemetryInfo()->time()->duration(self::$startTimes[$name]);
|
||||
|
||||
self::$timings[$name] = round($duration->asFloat(), 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collected timings.
|
||||
*
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public static function timings(): array
|
||||
{
|
||||
return self::$timings;
|
||||
}
|
||||
}
|
||||
34
src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php
Normal file
34
src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
|
||||
/**
|
||||
* Fires last for each test, after the outcome subscribers. Records the exact
|
||||
* assertion count so replay can emit the same `addToAssertionCount()` instead
|
||||
* of a hardcoded value.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
|
||||
{
|
||||
public function __construct(private readonly ResultCollector $collector) {}
|
||||
|
||||
public function notify(Finished $event): void
|
||||
{
|
||||
$test = $event->test();
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->recordAssertions(
|
||||
$test->className().'::'.$test->methodName(),
|
||||
$event->numberOfAssertionsPerformed(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user