mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 22:33:35 +02:00
Compare commits
425 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b6415ccd0 | |||
| a5d726b678 | |||
| 93223caa56 | |||
| d0572ce138 | |||
| b0d1fc589d | |||
| 5a4f124199 | |||
| c3ceb7d0c7 | |||
| 16d1208344 | |||
| 1cebc84c2d | |||
| 1f79660add | |||
| 37bdf60a1c | |||
| cc7acfe485 | |||
| 7bfb2a6185 | |||
| ca56ca5216 | |||
| b49ba062d5 | |||
| 09699847a2 | |||
| 545d0c2784 | |||
| b795af3b1b | |||
| a40cc6bc0e | |||
| 4d3d0105b7 | |||
| e90e4f70fc | |||
| d112582857 | |||
| 9f3c4e1e82 | |||
| 71f39366c2 | |||
| bfd5b75677 | |||
| 411b9954b5 | |||
| db70017cb2 | |||
| 86adaedbbd | |||
| 92c7677c6e | |||
| 872f0a50c2 | |||
| 668809bc75 | |||
| 8d8f45c843 | |||
| 086b3e9107 | |||
| 43fe26f324 | |||
| 865c1e5113 | |||
| 19eed8d581 | |||
| ad1850b110 | |||
| 585de259a2 | |||
| 953664dce0 | |||
| 1dd959848c | |||
| 047753c836 | |||
| b63626a94d | |||
| ae080becd6 | |||
| aa86304794 | |||
| 75963ea973 | |||
| 5d0ceb1a5d | |||
| 6f0c0536a4 | |||
| 6b2cd358e8 | |||
| e09e1be76d | |||
| 759ac63139 | |||
| 333f469efb | |||
| c4ec925aa5 | |||
| 5c8cfed483 | |||
| 962510b7e3 | |||
| e9c268a584 | |||
| fa91fcf744 | |||
| c6073511a2 | |||
| b5e1b83bb4 | |||
| 1c11595504 | |||
| cd0e921158 | |||
| f8c4c061f8 | |||
| 81eacd79fd | |||
| 1ef680c75d | |||
| 820fa08313 | |||
| 9e79e491e2 | |||
| 1b83b8fcda | |||
| ff4aba2ffa | |||
| feaac1ab7e | |||
| bbc6ada21e | |||
| 3927dbfcdf | |||
| b52bbb4cca | |||
| 52819501eb | |||
| 7b18287374 | |||
| 33e3bf2888 | |||
| 72ab506d7b | |||
| 5dc49a71d6 | |||
| 1adb484d3f | |||
| aadf7e92a6 | |||
| df42b9ed7b | |||
| 632ae79c8b | |||
| b5fed82883 | |||
| 145e20db75 | |||
| ee2e97e932 | |||
| b561236325 | |||
| e685179b7e | |||
| ac8feafdcc | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 87882a8561 | |||
| 77ef7e0df1 | |||
| 12100dd901 | |||
| 20c12d006e | |||
| 3aab02d2eb | |||
| 553aac65e6 | |||
| be49a3ce18 | |||
| 774a340400 | |||
| 04c9d41895 | |||
| dfb7b870af | |||
| 37821e1108 | |||
| cd711a25d5 | |||
| eee60c9e11 | |||
| c40c8dbc24 | |||
| 2d93c9c373 | |||
| 3d5bba93f8 | |||
| e5ab3af05b | |||
| 79bc7a8257 | |||
| 40b88b62ef | |||
| e3361bc321 | |||
| fc48c1bd1e | |||
| 92e76eb5ab | |||
| da726beffc | |||
| 4ef12b9aac | |||
| bd22f478b8 | |||
| eeaac34cf6 | |||
| b9b07d8983 | |||
| 6aa7d2f891 | |||
| 4d550cecfd | |||
| 1c21a7647a | |||
| d649de1988 | |||
| 783ca4bcd6 | |||
| ba07497219 | |||
| 34695843b3 | |||
| 1ca021dea6 | |||
| d17be9decd | |||
| b828ddcec7 | |||
| f859bb179d | |||
| 2fc75cfcf0 | |||
| 6cc48f63f8 | |||
| e0419d1328 | |||
| faa6988801 | |||
| c12247fafd | |||
| 29b4452443 | |||
| 1b168aba1c | |||
| 6aabd977cd | |||
| a882543c53 | |||
| c250b9da4f | |||
| 46bc3dc628 | |||
| d3ce498b8a | |||
| e1a4b98b71 | |||
| 9afbcd5c18 | |||
| 75593b6454 | |||
| 89590d6120 | |||
| fb0978c9bf | |||
| a3796daa42 | |||
| e3004db666 | |||
| 99cc4e0146 | |||
| a47e6f8fef | |||
| 536d79f765 | |||
| 65c0fbc528 | |||
| 9e4cf4b665 | |||
| 7bea819978 | |||
| 4280233b40 | |||
| d6db3a8a20 | |||
| 51c8ce4df6 | |||
| 5b8393b925 | |||
| e4d9b61fdf | |||
| e2d940cd53 | |||
| 380ccd30b4 | |||
| 31c200716d | |||
| 6add4da543 | |||
| 8ddcd3e853 | |||
| e3e178fd94 | |||
| 7b1ec9f003 | |||
| 1e48c5d473 | |||
| d00ec95dd9 | |||
| 89f3d6cb39 | |||
| a07a2e512a | |||
| 57eecb2b3d | |||
| 9f804dc954 | |||
| 7cbad4c589 | |||
| 5cae93b059 | |||
| df829ad19d | |||
| 635460653c | |||
| 1aa80dc398 | |||
| 8a14056111 | |||
| f247dd8e7b | |||
| 1c7c9754fd | |||
| 5f37939fda | |||
| 28305fcb7a | |||
| 5242803694 | |||
| 925935a7e8 | |||
| 460401c379 | |||
| 348b439172 | |||
| a4e77766c5 | |||
| 4a8c2d7d78 | |||
| 7d51601120 | |||
| 631bbe318b | |||
| 9b7c15d5b6 | |||
| 872796bd9b | |||
| c38d32ae86 | |||
| 6407c4f78f | |||
| 6e1bf63f6a | |||
| 1d3e8bb5dd | |||
| 3cc9b169e3 | |||
| c4911d046b | |||
| d0295f6168 | |||
| 21efbc3107 | |||
| e59b99cd73 | |||
| bf48e20880 | |||
| 53db68e005 | |||
| 34f1e9a7f2 | |||
| 57fd5ce042 | |||
| 3bcabfb63b | |||
| aa3a7c303a | |||
| 5c08a135f7 | |||
| 6e0e030d71 | |||
| b2c07561e7 | |||
| 97600b6f0b | |||
| 8a51f15d65 | |||
| a349f53964 | |||
| a725e774c0 | |||
| bed5e5b54a | |||
| 45b1d4ce20 | |||
| d106b70766 | |||
| 6ac6c1518e | |||
| fda515a17f | |||
| 0a97d3a288 | |||
| 3802fa80e6 | |||
| 5c3cbc14d2 | |||
| 6b9c768172 | |||
| 4a2fc179ae | |||
| b5bb2139dc | |||
| 07416a3c61 | |||
| 30b94e3034 | |||
| be34eecb2f | |||
| 5d9f95f8d4 | |||
| 48b70a03d5 | |||
| 4b8642b972 | |||
| 8711d51eac | |||
| 58dfb6da64 | |||
| d7735d1faa | |||
| 6b59166f3c | |||
| 3a26028d17 | |||
| 3c91bf4ad2 | |||
| 6a434be0f6 | |||
| f355b99bbf | |||
| 95a00341e9 | |||
| 466259646d | |||
| 00f8d56083 | |||
| ca2dca592d | |||
| 405d8d4406 | |||
| b944ee5841 | |||
| f4e22dcafe | |||
| 339c1e8cac | |||
| d4c7362132 | |||
| 81bfdbf8fe | |||
| f45cbf43c5 | |||
| b9088d23fb | |||
| 7250185423 | |||
| e457eb0e9c | |||
| 48357c6f30 | |||
| b46f051550 | |||
| 3d3c5d41ac | |||
| caabebf2a1 | |||
| 470a5833d4 | |||
| c1feefbb9e | |||
| e876dba8ba | |||
| d9c18f9c02 | |||
| 660b57b365 | |||
| 68527c996f | |||
| c6a42a2b28 | |||
| fcf5c27914 | |||
| 856a370032 | |||
| e24882c486 | |||
| 51fc380789 | |||
| f6609f4039 | |||
| 2941f9821f | |||
| ed399af43e | |||
| 0d66dc4322 | |||
| 7e4280bf83 | |||
| a5915b16ab | |||
| 1476b529a1 | |||
| 2892341c28 | |||
| 59e781e77b | |||
| 55a3394f8c | |||
| 0d99c33c4e | |||
| adc5aae6f8 | |||
| 980667e845 | |||
| 8c849c5f40 | |||
| 47f1fc2d94 | |||
| 9c8033d60c | |||
| 42d1092a9e | |||
| c7e32f5d33 | |||
| d379128cc4 | |||
| f09d6f2064 | |||
| 494cc6e2a4 | |||
| f52a455773 | |||
| 184f5d2742 | |||
| 1d81069a2a | |||
| 4b9bb77b54 | |||
| c440031e28 | |||
| 18bbca748f | |||
| bff44562a9 | |||
| 9ebb990f96 | |||
| f142aad8ad | |||
| cabff738f7 | |||
| 0746173a32 | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 87db0b4847 | |||
| 6ba373a772 | |||
| 945d476409 | |||
| a8cf0fe2cb | |||
| 2ae072bb95 | |||
| 59d066950c | |||
| 0dd1aa72ef | |||
| 4e03cd3edb | |||
| eeab24e2bb | |||
| 9b64d5425a | |||
| 0acab1cbb4 | |||
| e616eab9fb | |||
| 7cbb1fcdb2 | |||
| cb5f6e1bd2 | |||
| 985dadd934 | |||
| 10aee6045c | |||
| 4ac14b2528 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| 13c322bab3 | |||
| 3855249ce9 | |||
| f528bd8427 | |||
| acd8aafa63 | |||
| e8d630e774 | |||
| b6385dc865 | |||
| 02dc8d7bcc | |||
| 729f18a152 | |||
| bdf60cea91 | |||
| 3a8ee8291c | |||
| 654cb726c9 | |||
| bce26aeaad | |||
| 5948bcd71e | |||
| 89006d83a9 | |||
| a8e974d64a | |||
| 617b074049 | |||
| 2eea71a664 | |||
| 4b5374d507 | |||
| 9085561ece | |||
| b71bfc513a | |||
| 75938ac9eb | |||
| e766825f5b | |||
| 8a83a1a1a9 | |||
| e63a886f98 | |||
| 109bb22c5e | |||
| 89dd212d84 | |||
| cd07c6d966 | |||
| 8dddb47ad5 | |||
| 3a6c2fab37 | |||
| 281dbf6cf4 | |||
| 40c8429058 | |||
| 8dd650fd05 | |||
| d9d46c73f8 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| e44c554a0b | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 9797a71dbc | |||
| c1a54df233 | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| ce05ee9aad | |||
| 3d2ebdb273 | |||
| 668ca9f5de | |||
| f47b74445b | |||
| 6c42e7f4ea | |||
| be3ff37517 | |||
| a087555383 | |||
| 4b50cb486d | |||
| f7175ecfd7 | |||
| 07737bc0b2 | |||
| f659a45311 | |||
| e6ab897594 | |||
| a753b41409 | |||
| 1a4c06bd6e | |||
| 12c1da29ee | |||
| 5d42e8fe3a | |||
| 9d17b872dd | |||
| 2a80101f42 | |||
| f7015fe59c | |||
| 7281e0ded7 | |||
| 1675dd1d41 | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 | |||
| df7b6c8454 | |||
| 5de8693e3b | |||
| 7d80f1d20e | |||
| b3119cc120 | |||
| 4e294edf76 | |||
| f96a1b2786 | |||
| 1f39b28e2c | |||
| b081584ab6 | |||
| 6966802afc | |||
| c61dcad42b | |||
| ec3e0b2d33 | |||
| c3620840b4 | |||
| 10a19f16ba | |||
| a956de5446 | |||
| e6f511302b | |||
| 0e7c2abe8b | |||
| bd5fed9e12 | |||
| 26345fd9f4 | |||
| ae1da79ac1 | |||
| dc9a1e8ace |
@@ -0,0 +1,13 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
|
||||||
|
|
||||||
|
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
|
||||||
|
|
||||||
|
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
|
||||||
|
|
||||||
|
All security vulnerabilities will be promptly addressed.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 5
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
@@ -2,34 +2,38 @@ name: Static Analysis
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [4.x]
|
branches: [5.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * *'
|
- cron: '0 9 * * *'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
static:
|
static:
|
||||||
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
||||||
name: Static Tests
|
name: Static Tests
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
dependency-version: [prefer-lowest, prefer-stable]
|
dependency-version: [prefer-stable]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.3
|
php-version: 8.4
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
coverage: none
|
coverage: none
|
||||||
extensions: sockets
|
extensions: sockets
|
||||||
@@ -40,15 +44,17 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
|
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||||
static-php-8.3-composer-
|
static-php-8.4-composer-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
env:
|
||||||
|
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||||
|
|
||||||
- name: Profanity Check
|
- name: Profanity Check
|
||||||
@@ -60,8 +66,5 @@ jobs:
|
|||||||
- name: Type Coverage
|
- name: Type Coverage
|
||||||
run: composer test:type:coverage
|
run: composer test:type:coverage
|
||||||
|
|
||||||
- name: Refacto
|
|
||||||
run: composer test:refacto
|
|
||||||
|
|
||||||
- name: Style
|
- name: Style
|
||||||
run: composer test:lint
|
run: composer test:lint
|
||||||
|
|||||||
+15
-10
@@ -2,37 +2,40 @@ name: Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [4.x]
|
branches: [5.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 9 * * *'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||||
symfony: ['7.4', '8.0']
|
symfony: ['8.0']
|
||||||
php: ['8.3', '8.4', '8.5']
|
php: ['8.4', '8.5']
|
||||||
dependency_version: [prefer-stable]
|
dependency_version: [prefer-stable]
|
||||||
exclude:
|
|
||||||
- php: '8.3'
|
|
||||||
symfony: '8.0'
|
|
||||||
|
|
||||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
@@ -45,10 +48,10 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json') }}
|
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
|
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
|
||||||
${{ matrix.os }}-php-${{ matrix.php }}-composer-
|
${{ matrix.os }}-php-${{ matrix.php }}-composer-
|
||||||
@@ -60,6 +63,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Install PHP dependencies
|
- name: Install PHP dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||||
|
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
.idea/*
|
.idea/*
|
||||||
.idea/codeStyleSettings.xml
|
.idea/codeStyleSettings.xml
|
||||||
.temp/*
|
.temp/*
|
||||||
composer.lock
|
/composer.lock
|
||||||
/vendor/
|
/vendor/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
|
||||||
|
|
||||||
|
## Ask before testing
|
||||||
|
|
||||||
|
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
|
||||||
|
|
||||||
|
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
|
||||||
|
|
||||||
|
Once the user confirms:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer test:unit # fast, excludes the visual group
|
||||||
|
composer test:integration # visual and snapshot tests
|
||||||
|
composer test # everything CI runs, in CI's order
|
||||||
|
composer update:snapshots # only when a test was added or removed
|
||||||
|
```
|
||||||
|
|
||||||
|
## TIA scenario tests
|
||||||
|
|
||||||
|
`tests/Features/Tia/*` scaffold a throwaway git project, run a real `pest` subprocess against it, and diff the TIA graph it wrote. They exist because TIA's contract is about what a run *writes* — replay, branch keys, and the COMPLETE / RESULTS-ONLY / HARD-SUPPRESSED tiers are invisible to ordinary assertions, and every case used to be measured by hand against a playground app.
|
||||||
|
|
||||||
|
Add one whenever a change touches branch resolution, replay, filtered mode, or the write tiers. How:
|
||||||
|
|
||||||
|
- `Project::make('master')` scaffolds; `seed('master')` writes a graph and sentinels every cached result (`time=9.999`, `assertions=42`) so any rewrite shows up.
|
||||||
|
- `$project->pest('--tia', …)` runs it; `$project->delta()` compares against that snapshot. `writtenCount()` is the discriminator — `0` means "replayed", not "wrote the same values". `mutateGraph()` bends one entry; overlays in `tests/Fixtures/Tia/overlays/<name>/` supply a different `tests/Pest.php`.
|
||||||
|
- Keep expectations driver-independent: a cold recording run needs pcov/xdebug and behaves differently without one. Seed a graph instead of recording one.
|
||||||
|
|
||||||
|
Run them by file (a directory argument finds nothing) or by `--filter`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php bin/pest tests/Features/Tia/PartialRunWriteTier.php
|
||||||
|
```
|
||||||
@@ -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
|
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
|
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
|
||||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
<a href="https://packagist.org/packages/pestphp/pest"><img alt="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="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
||||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
||||||
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
|
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
|
||||||
|
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
------
|
------
|
||||||
|
|
||||||
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
|
||||||
|
|
||||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
||||||
|
|
||||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||||
- Follow the creator Nuno Maduro:
|
- Follow the creator Nuno Maduro:
|
||||||
@@ -28,26 +29,13 @@
|
|||||||
|
|
||||||
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
|
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
|
||||||
|
|
||||||
|
|
||||||
### Platinum Sponsors
|
|
||||||
|
|
||||||
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
|
||||||
- **[Devin](https://devin.ai/?ref=nunomaduro)**
|
|
||||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
|
||||||
- **[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)**
|
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||||
|
- **[PhpStorm](https://jb.gg/nuno)**
|
||||||
### Premium Sponsors
|
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||||
|
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||||
- [Zapiet](https://zapiet.com/?ref=pestphp)
|
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||||
- [Load Forge](https://loadforge.com/?ref=pestphp)
|
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||||
- [Route4Me](https://route4me.com/pt?ref=pestphp)
|
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||||
- [Nerdify](https://getnerdify.com/?ref=pestphp)
|
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||||
- [Akaunting](https://akaunting.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)**.
|
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||||
|
|||||||
+3
-3
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
When releasing a new version of Pest there are some checks and updates that need to be done:
|
When releasing a new version of Pest there are some checks and updates that need to be done:
|
||||||
|
|
||||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
> **For Pest v4 you should use the `4.x` branch instead.**
|
||||||
|
|
||||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
|
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
||||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
|
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
|
||||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||||
- Run the tests locally using: `composer test`
|
- Run the tests locally using: `composer test`
|
||||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Pest\Contracts\Restarter;
|
||||||
use Pest\Kernel;
|
use Pest\Kernel;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
|
use Pest\Support\Container;
|
||||||
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\NotesTestCaseFilter;
|
use Pest\TestCaseMethodFilters\NotesTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\PrTestCaseFilter;
|
use Pest\TestCaseMethodFilters\PrTestCaseFilter;
|
||||||
|
use Pest\TestCaseMethodFilters\FlakyTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\TodoTestCaseFilter;
|
use Pest\TestCaseMethodFilters\TodoTestCaseFilter;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
@@ -23,6 +26,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
|
|
||||||
$dirty = false;
|
$dirty = false;
|
||||||
$todo = false;
|
$todo = false;
|
||||||
|
$flaky = false;
|
||||||
$notes = false;
|
$notes = false;
|
||||||
|
|
||||||
foreach ($arguments as $key => $value) {
|
foreach ($arguments as $key => $value) {
|
||||||
@@ -57,6 +61,11 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
unset($arguments[$key]);
|
unset($arguments[$key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($value === '--flaky') {
|
||||||
|
$flaky = true;
|
||||||
|
unset($arguments[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($value === '--notes') {
|
if ($value === '--notes') {
|
||||||
$notes = true;
|
$notes = true;
|
||||||
unset($arguments[$key]);
|
unset($arguments[$key]);
|
||||||
@@ -135,6 +144,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
|
|
||||||
// Get $rootPath based on $autoloadPath
|
// Get $rootPath based on $autoloadPath
|
||||||
$rootPath = dirname($autoloadPath, 2);
|
$rootPath = dirname($autoloadPath, 2);
|
||||||
|
|
||||||
$input = new ArgvInput;
|
$input = new ArgvInput;
|
||||||
|
|
||||||
$testSuite = TestSuite::getInstance(
|
$testSuite = TestSuite::getInstance(
|
||||||
@@ -150,6 +160,10 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter);
|
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($flaky) {
|
||||||
|
$testSuite->tests->addTestCaseMethodFilter(new FlakyTestCaseFilter);
|
||||||
|
}
|
||||||
|
|
||||||
if ($notes) {
|
if ($notes) {
|
||||||
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
|
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
|
||||||
}
|
}
|
||||||
@@ -181,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
try {
|
try {
|
||||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||||
|
|
||||||
|
$container = Container::getInstance();
|
||||||
|
|
||||||
|
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||||
|
$restarter = $container->get($restarterClass);
|
||||||
|
assert($restarter instanceof Restarter);
|
||||||
|
|
||||||
|
$restarter->maybeRestart($rootPath, $originalArguments);
|
||||||
|
}
|
||||||
|
|
||||||
$result = $kernel->handle($originalArguments, $arguments);
|
$result = $kernel->handle($originalArguments, $arguments);
|
||||||
|
|
||||||
$kernel->terminate();
|
$kernel->terminate();
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { resolve, relative, extname, sep, join } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const PAGE_EXTENSIONS = new Set([
|
||||||
|
'.vue', '.svelte',
|
||||||
|
'.tsx', '.jsx',
|
||||||
|
'.ts', '.js',
|
||||||
|
'.mts', '.cts', '.mjs', '.cjs',
|
||||||
|
])
|
||||||
|
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
|
||||||
|
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
||||||
|
const PAGE_DIR_CANDIDATES = [
|
||||||
|
'resources/js/Pages',
|
||||||
|
'resources/js/pages',
|
||||||
|
'assets/js/Pages',
|
||||||
|
'assets/js/pages',
|
||||||
|
'assets/Pages',
|
||||||
|
'assets/pages',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadRolldown() {
|
||||||
|
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||||
|
let path = null
|
||||||
|
|
||||||
|
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||||
|
|
||||||
|
if (path === null) {
|
||||||
|
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||||
|
// dependency of the vite package rather than a top-level install.
|
||||||
|
try {
|
||||||
|
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||||
|
path = viteRequire.resolve('rolldown')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === null) return null
|
||||||
|
|
||||||
|
return await import(pathToFileURL(path).href)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripJsonComments(raw) {
|
||||||
|
let out = ''
|
||||||
|
let inString = false
|
||||||
|
let quote = ''
|
||||||
|
let inLine = false
|
||||||
|
let inBlock = false
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const c = raw[i]
|
||||||
|
const n = raw[i + 1]
|
||||||
|
|
||||||
|
if (inLine) {
|
||||||
|
if (c === '\n') { inLine = false; out += c }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inBlock) {
|
||||||
|
if (c === '*' && n === '/') { inBlock = false; i++ }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inString) {
|
||||||
|
out += c
|
||||||
|
if (c === '\\') { out += n ?? ''; i++; continue }
|
||||||
|
if (c === quote) inString = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
|
||||||
|
if (c === '/' && n === '/') { inLine = true; i++; continue }
|
||||||
|
if (c === '/' && n === '*') { inBlock = true; i++; continue }
|
||||||
|
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
|
||||||
|
out += c
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonWithComments(path) {
|
||||||
|
const raw = await readFile(path, 'utf8')
|
||||||
|
return JSON.parse(stripJsonComments(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||||
|
const alias = {}
|
||||||
|
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||||
|
const p = join(projectRoot, name)
|
||||||
|
if (!existsSync(p)) continue
|
||||||
|
let cfg
|
||||||
|
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||||
|
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||||
|
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||||
|
for (const [key, targets] of Object.entries(paths)) {
|
||||||
|
if (!key.endsWith('/*')) continue
|
||||||
|
const t0 = Array.isArray(targets) ? targets[0] : null
|
||||||
|
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
|
||||||
|
const aliasKey = key.slice(0, -2)
|
||||||
|
if (alias[aliasKey] !== undefined) continue
|
||||||
|
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
|
||||||
|
const VITE_CONFIG_FILES = [
|
||||||
|
'vite.config.ts',
|
||||||
|
'vite.config.js',
|
||||||
|
'vite.config.mjs',
|
||||||
|
'vite.config.cjs',
|
||||||
|
'vite.config.mts',
|
||||||
|
'vite.config.cts',
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveAliasTarget(projectRoot, target) {
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
// Vite resolves a leading slash against the project root, unless the
|
||||||
|
// config author used a genuinely absolute path.
|
||||||
|
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(projectRoot, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usesLaravelVitePlugin(projectRoot) {
|
||||||
|
const p = join(projectRoot, 'package.json')
|
||||||
|
if (!existsSync(p)) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
||||||
|
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
||||||
|
const alias = {}
|
||||||
|
let source = null
|
||||||
|
|
||||||
|
for (const name of VITE_CONFIG_FILES) {
|
||||||
|
const p = join(projectRoot, name)
|
||||||
|
if (!existsSync(p)) continue
|
||||||
|
try { source = await readFile(p, 'utf8') } catch { continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== null) {
|
||||||
|
// The config is executable code we cannot import safely, so extract alias
|
||||||
|
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
||||||
|
// the value expression — covers `'@': '/resources/js'` as well as
|
||||||
|
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
||||||
|
// top-level comma; call arguments are kept via the paren group.
|
||||||
|
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
||||||
|
const key = m[1]
|
||||||
|
if (alias[key] !== undefined) continue
|
||||||
|
|
||||||
|
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
||||||
|
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
||||||
|
if (!target || target.includes('*')) continue
|
||||||
|
|
||||||
|
alias[key] = resolveAliasTarget(projectRoot, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// laravel-vite-plugin registers '@' → resources/js by default.
|
||||||
|
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
||||||
|
alias['@'] = resolve(projectRoot, 'resources/js')
|
||||||
|
}
|
||||||
|
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listPageFiles(pagesDir) {
|
||||||
|
if (!existsSync(pagesDir)) return []
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
const walk = async (dir) => {
|
||||||
|
let entries
|
||||||
|
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = resolve(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) { await walk(full); continue }
|
||||||
|
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(pagesDir)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverPagesDir() {
|
||||||
|
const override = process.env.TIA_VITE_PAGES_DIR
|
||||||
|
if (override && override.length > 0) {
|
||||||
|
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rel of PAGE_DIR_CANDIDATES) {
|
||||||
|
const abs = resolve(PROJECT_ROOT, rel)
|
||||||
|
if (!existsSync(abs)) continue
|
||||||
|
const files = await listPageFiles(abs)
|
||||||
|
if (files.length > 0) return abs
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentNameFor(pageAbs, pagesDir) {
|
||||||
|
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
||||||
|
const ext = extname(rel)
|
||||||
|
return rel.slice(0, rel.length - ext.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalSpecifier(source, aliasKeys) {
|
||||||
|
if (source.startsWith('.') || source.startsWith('/')) return true
|
||||||
|
for (const key of aliasKeys) {
|
||||||
|
if (source === key || source.startsWith(key + '/')) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pagesDir = await discoverPagesDir()
|
||||||
|
|
||||||
|
if (pagesDir === null) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await listPageFiles(pagesDir)
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadRolldown()
|
||||||
|
|
||||||
|
if (loaded === null) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rolldown } = loaded
|
||||||
|
// The vite config is what the dev server actually resolves with — let it win.
|
||||||
|
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
||||||
|
const aliasKeys = Object.keys(alias)
|
||||||
|
|
||||||
|
const graph = new Map()
|
||||||
|
|
||||||
|
const collector = {
|
||||||
|
name: 'pest-tia-collector',
|
||||||
|
moduleParsed(info) {
|
||||||
|
const id = info.id
|
||||||
|
if (!id || id.startsWith('\0')) return
|
||||||
|
const deps = new Set()
|
||||||
|
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||||
|
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||||
|
graph.set(id, deps)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalBare = {
|
||||||
|
name: 'pest-tia-external-bare',
|
||||||
|
resolveId(source) {
|
||||||
|
if (!source) return null
|
||||||
|
if (isLocalSpecifier(source, aliasKeys)) return null
|
||||||
|
return { id: source, external: true }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetStub = {
|
||||||
|
name: 'pest-tia-asset-stub',
|
||||||
|
load(id) {
|
||||||
|
if (!id) return null
|
||||||
|
if (ASSET_EXT_RE.test(id)) {
|
||||||
|
return { code: 'export default null', moduleSideEffects: false }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = Object.create(null)
|
||||||
|
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
||||||
|
|
||||||
|
const bundle = await rolldown({
|
||||||
|
input,
|
||||||
|
cwd: PROJECT_ROOT,
|
||||||
|
resolve: {
|
||||||
|
alias,
|
||||||
|
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||||
|
},
|
||||||
|
transform: { jsx: 'preserve' },
|
||||||
|
treeshake: false,
|
||||||
|
plugins: [externalBare, assetStub, collector],
|
||||||
|
logLevel: 'silent',
|
||||||
|
onLog: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bundle.generate({ format: 'esm' })
|
||||||
|
} finally {
|
||||||
|
await bundle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const reverse = new Map()
|
||||||
|
const transitiveCache = new Map()
|
||||||
|
|
||||||
|
const computeTransitive = (id, stack) => {
|
||||||
|
const cached = transitiveCache.get(id)
|
||||||
|
if (cached) return cached
|
||||||
|
if (stack.has(id)) return null
|
||||||
|
|
||||||
|
stack.add(id)
|
||||||
|
const acc = new Set()
|
||||||
|
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
||||||
|
// that dependency's subtree and must not be memoized — the ancestor call
|
||||||
|
// completes it for the current traversal, but a cached copy would leak the
|
||||||
|
// incomplete set into other pages' traversals.
|
||||||
|
let complete = true
|
||||||
|
const deps = graph.get(id)
|
||||||
|
if (deps) {
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (!dep || dep.startsWith('\0')) continue
|
||||||
|
if (dep.startsWith(PROJECT_ROOT)) {
|
||||||
|
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||||
|
acc.add(rel)
|
||||||
|
}
|
||||||
|
if (stack.has(dep)) {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const child = computeTransitive(dep, stack)
|
||||||
|
if (child) for (const r of child) acc.add(r)
|
||||||
|
if (!transitiveCache.has(dep)) complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.delete(id)
|
||||||
|
if (complete) transitiveCache.set(id, acc)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
const pageComponent = componentNameFor(page, pagesDir)
|
||||||
|
const reachable = computeTransitive(page, new Set())
|
||||||
|
if (!reachable) continue
|
||||||
|
for (const rel of reachable) {
|
||||||
|
const bucket = reverse.get(rel) ?? new Set()
|
||||||
|
bucket.add(pageComponent)
|
||||||
|
reverse.set(rel, bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Object.create(null)
|
||||||
|
const keys = [...reverse.keys()].sort()
|
||||||
|
for (const key of keys) {
|
||||||
|
payload[key] = [...reverse.get(key)].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokedDirectly = process.argv[1] !== undefined
|
||||||
|
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
|
||||||
|
if (invokedDirectly) {
|
||||||
|
try {
|
||||||
|
await main()
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
|
|||||||
use ParaTest\WrapperRunner\WrapperWorker;
|
use ParaTest\WrapperRunner\WrapperWorker;
|
||||||
use Pest\Kernel;
|
use Pest\Kernel;
|
||||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||||
|
use Pest\Support\Container;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||||
@@ -58,6 +59,15 @@ $bootPest = (static function (): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$container = Container::getInstance();
|
||||||
|
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
|
||||||
|
|
||||||
|
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||||
|
$restarter = $container->get($restarterClass);
|
||||||
|
|
||||||
|
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
|
||||||
|
}
|
||||||
|
|
||||||
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
|
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
|
||||||
$statusFile = fopen($getopt['status-file'], 'wb');
|
$statusFile = fopen($getopt['status-file'], 'wb');
|
||||||
assert(is_resource($statusFile));
|
assert(is_resource($statusFile));
|
||||||
|
|||||||
+31
-19
@@ -17,20 +17,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3.0",
|
"php": "^8.4",
|
||||||
"brianium/paratest": "^7.19.0",
|
"brianium/paratest": "^7.23.1",
|
||||||
"nunomaduro/collision": "^8.9.0",
|
"nunomaduro/collision": "^8.9.5",
|
||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^4.0.0",
|
"pestphp/pest-plugin": "^5.0.0",
|
||||||
"pestphp/pest-plugin-arch": "^4.0.0",
|
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||||
"phpunit/phpunit": "^12.5.12",
|
"phpunit/phpunit": "^13.2.6",
|
||||||
"symfony/process": "^7.4.5|^8.0.5"
|
"symfony/process": "^8.1.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"filp/whoops": "<2.18.3",
|
"filp/whoops": "<2.18.3",
|
||||||
"phpunit/phpunit": ">12.5.12",
|
"phpunit/phpunit": ">13.2.6",
|
||||||
"sebastian/exporter": "<7.0.0",
|
"sebastian/exporter": "<7.0.0",
|
||||||
"webmozart/assert": "<1.11.0"
|
"webmozart/assert": "<1.11.0"
|
||||||
},
|
},
|
||||||
@@ -48,23 +48,30 @@
|
|||||||
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
||||||
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
||||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||||
|
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
|
||||||
"Tests\\": "tests/PHPUnit/"
|
"Tests\\": "tests/PHPUnit/"
|
||||||
},
|
},
|
||||||
|
"classmap": [
|
||||||
|
"tests/Fixtures/Arch/ToBeCasedCorrectly/IncorrectCasing/incorrectCasing.php"
|
||||||
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"tests/Autoload.php"
|
"tests/Autoload.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"pestphp/pest-dev-tools": "^4.1.0",
|
"pestphp/pest-dev-tools": "^5.0.0",
|
||||||
"pestphp/pest-plugin-browser": "^4.3.0",
|
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||||
"pestphp/pest-plugin-type-coverage": "^4.0.3",
|
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||||
"psy/psysh": "^0.12.20"
|
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||||
|
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||||
|
"psy/psysh": "^0.12.24"
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"config": {
|
"config": {
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
|
"process-timeout": 0,
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"pestphp/pest-plugin": true
|
"pestphp/pest-plugin": true
|
||||||
}
|
}
|
||||||
@@ -73,10 +80,14 @@
|
|||||||
"bin/pest"
|
"bin/pest"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"refacto": "rector",
|
"lint": [
|
||||||
"lint": "pint --parallel",
|
"rector",
|
||||||
"test:refacto": "rector --dry-run",
|
"pint --parallel"
|
||||||
"test:lint": "pint --parallel --test",
|
],
|
||||||
|
"test:lint": [
|
||||||
|
"rector --dry-run",
|
||||||
|
"pint --parallel --test"
|
||||||
|
],
|
||||||
"test:profanity": "php bin/pest --profanity --compact",
|
"test:profanity": "php bin/pest --profanity --compact",
|
||||||
"test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug",
|
"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:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100",
|
||||||
@@ -84,9 +95,9 @@
|
|||||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||||
"test:integration": "php bin/pest --group=integration -v",
|
"test:integration": "php bin/pest --group=integration -v",
|
||||||
|
"test:tia": "php bin/pest --group=tia -v",
|
||||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||||
"test": [
|
"test": [
|
||||||
"@test:refacto",
|
|
||||||
"@test:lint",
|
"@test:lint",
|
||||||
"@test:type:check",
|
"@test:type:check",
|
||||||
"@test:type:coverage",
|
"@test:type:coverage",
|
||||||
@@ -116,6 +127,7 @@
|
|||||||
"Pest\\Plugins\\Verbose",
|
"Pest\\Plugins\\Verbose",
|
||||||
"Pest\\Plugins\\Version",
|
"Pest\\Plugins\\Version",
|
||||||
"Pest\\Plugins\\Shard",
|
"Pest\\Plugins\\Shard",
|
||||||
|
"Pest\\Plugins\\Tia",
|
||||||
"Pest\\Plugins\\Parallel"
|
"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"]
|
|
||||||
@@ -1,6 +1,39 @@
|
|||||||
<?php
|
<?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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This file is part of PHPUnit.
|
* This file is part of PHPUnit.
|
||||||
*
|
*
|
||||||
@@ -14,6 +47,9 @@ namespace PHPUnit\Logging\JUnit;
|
|||||||
|
|
||||||
use DOMDocument;
|
use DOMDocument;
|
||||||
use DOMElement;
|
use DOMElement;
|
||||||
|
use Pest\Logging\Converter;
|
||||||
|
use Pest\Support\Container;
|
||||||
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Event\Code\Test;
|
use PHPUnit\Event\Code\Test;
|
||||||
use PHPUnit\Event\Code\TestMethod;
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
use PHPUnit\Event\EventFacadeIsSealedException;
|
use PHPUnit\Event\EventFacadeIsSealedException;
|
||||||
@@ -50,7 +86,7 @@ final class JunitXmlLogger
|
|||||||
{
|
{
|
||||||
private readonly Printer $printer;
|
private readonly Printer $printer;
|
||||||
|
|
||||||
private readonly \Pest\Logging\Converter $converter; // pest-added
|
private readonly Converter $converter; // pest-added
|
||||||
|
|
||||||
private DOMDocument $document;
|
private DOMDocument $document;
|
||||||
|
|
||||||
@@ -108,7 +144,7 @@ final class JunitXmlLogger
|
|||||||
public function __construct(Printer $printer, Facade $facade)
|
public function __construct(Printer $printer, Facade $facade)
|
||||||
{
|
{
|
||||||
$this->printer = $printer;
|
$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->registerSubscribers($facade);
|
||||||
$this->createDocument();
|
$this->createDocument();
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The MIT License (MIT)
|
||||||
|
*
|
||||||
|
* Copyright (c) 2011 Brian Scaturro
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ParaTest\WrapperRunner;
|
||||||
|
|
||||||
|
use PHPUnit\TextUI\Output\Printer;
|
||||||
|
|
||||||
|
use function preg_match;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||||
|
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||||
|
* instead of being treated as unexpected output.
|
||||||
|
*/
|
||||||
|
final readonly class ProgressPrinterOutput implements Printer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Printer $progressPrinter,
|
||||||
|
private Printer $outputPrinter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function print(string $buffer): void
|
||||||
|
{
|
||||||
|
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||||
|
if (
|
||||||
|
$buffer === "\n"
|
||||||
|
|| preg_match('/^ +$/', $buffer) === 1
|
||||||
|
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($buffer) {
|
||||||
|
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
||||||
|
default => $this->outputPrinter->print($buffer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flush(): void
|
||||||
|
{
|
||||||
|
$this->progressPrinter->flush();
|
||||||
|
$this->outputPrinter->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
|||||||
(static function () use ($suiteClassFile) {
|
(static function () use ($suiteClassFile) {
|
||||||
try {
|
try {
|
||||||
include_once $suiteClassFile;
|
include_once $suiteClassFile;
|
||||||
|
|
||||||
|
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Panic::with($e);
|
Panic::with($e);
|
||||||
}
|
}
|
||||||
|
|
||||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$loadedClasses = array_values(
|
$loadedClasses = array_values(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace Pest\Logging\TeamCity\Subscriber;
|
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||||
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
use PHPUnit\Event\Test\Skipped;
|
||||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||||
|
*
|
||||||
|
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||||
|
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||||
|
* "T" and swallows the "S" that follows it.
|
||||||
*/
|
*/
|
||||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||||
{
|
{
|
||||||
public function notify(Skipped $event): void
|
public function notify(Skipped $event): void
|
||||||
{
|
{
|
||||||
if (str_contains($event->message(), '__TODO__')) {
|
if ($event->message() === '__TODO__') {
|
||||||
$this->printTodoItem();
|
$this->printTodoItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger()->testSkipped($event);
|
$this->printer()->testSkipped();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
|||||||
private function printTodoItem(): void
|
private function printTodoItem(): void
|
||||||
{
|
{
|
||||||
$mirror = new ReflectionClass($this->printer());
|
$mirror = new ReflectionClass($this->printer());
|
||||||
$printerMirror = $mirror->getMethod('printProgress');
|
$printProgress = $mirror->getMethod('printProgress');
|
||||||
$printerMirror->invoke($this->printer(), 'T');
|
$printProgress->invoke($this->printer(), 'T');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
parameters:
|
parameters:
|
||||||
ignoreErrors:
|
ignoreErrors:
|
||||||
-
|
|
||||||
message: '#^Parameter \#1 of callable callable\(Pest\\Expectation\<string\|null\>\)\: Pest\\Arch\\Contracts\\ArchExpectation expects Pest\\Expectation\<string\|null\>, Pest\\Expectation\<string\|null\> given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/ArchPresets/AbstractPreset.php
|
|
||||||
|
|
||||||
-
|
-
|
||||||
message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#'
|
message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#'
|
||||||
identifier: trait.unused
|
identifier: trait.unused
|
||||||
@@ -24,12 +18,6 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: src/Concerns/Testable.php
|
path: src/Concerns/Testable.php
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Loose comparison using \!\= between \(Closure\|null\) and false will always evaluate to false\.$#'
|
|
||||||
identifier: notEqual.alwaysFalse
|
|
||||||
count: 1
|
|
||||||
path: src/Expectation.php
|
|
||||||
|
|
||||||
-
|
-
|
||||||
message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\<TAndValue\> but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\<TAndValue of mixed\>\.$#'
|
message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\<TAndValue\> but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\<TAndValue of mixed\>\.$#'
|
||||||
identifier: return.type
|
identifier: return.type
|
||||||
@@ -102,78 +90,12 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: src/PendingCalls/TestCall.php
|
path: src/PendingCalls/TestCall.php
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#1 \$argv of class Symfony\\Component\\Console\\Input\\ArgvInput constructor expects list\<string\>\|null, array\<int, string\> given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#13 \$testRunnerTriggeredDeprecationEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestRunner\\DeprecationTriggered\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#14 \$testRunnerTriggeredWarningEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestRunner\\WarningTriggered\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#15 \$errors of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#16 \$deprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#17 \$notices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#18 \$warnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#19 \$phpDeprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#20 \$phpNotices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#21 \$phpWarnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
|
|
||||||
identifier: argument.type
|
|
||||||
count: 1
|
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.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\.$#'
|
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
|
identifier: argument.type
|
||||||
count: 1
|
count: 1
|
||||||
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
|
||||||
|
|
||||||
-
|
|
||||||
message: '#^Parameter \#5 \$testFailedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\Failed\>, 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\.$#'
|
message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestSuite\\Skipped\>, array given\.$#'
|
||||||
identifier: argument.type
|
identifier: argument.type
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
services:
|
||||||
|
-
|
||||||
|
class: Pest\PHPStan\HigherOrderExpectationTypeExtension
|
||||||
|
tags:
|
||||||
|
- phpstan.broker.expressionTypeResolverExtension
|
||||||
+2
-3
@@ -1,5 +1,7 @@
|
|||||||
includes:
|
includes:
|
||||||
- phpstan-baseline.neon
|
- phpstan-baseline.neon
|
||||||
|
- phpstan-pest-extension.neon
|
||||||
|
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
level: 7
|
level: 7
|
||||||
@@ -7,6 +9,3 @@ parameters:
|
|||||||
- src
|
- src
|
||||||
|
|
||||||
reportUnmatchedIgnoredErrors: false
|
reportUnmatchedIgnoredErrors: false
|
||||||
|
|
||||||
ignoreErrors:
|
|
||||||
- "#type mixed is not subtype of native#"
|
|
||||||
|
|||||||
+2
-1
@@ -18,8 +18,9 @@
|
|||||||
<directory suffix=".php">./tests</directory>
|
<directory suffix=".php">./tests</directory>
|
||||||
<directory suffix=".php">./tests-external</directory>
|
<directory suffix=".php">./tests-external</directory>
|
||||||
<exclude>./tests/.snapshots</exclude>
|
<exclude>./tests/.snapshots</exclude>
|
||||||
<exclude>./tests/.tests</exclude>
|
|
||||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||||
|
<exclude>./tests/Fixtures/Suites</exclude>
|
||||||
|
<exclude>./tests/Fixtures/Tia</exclude>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
|
|||||||
+39
@@ -2,22 +2,61 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||||
|
use Pest\Rector\Set\PestSetList;
|
||||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||||
use Rector\Config\RectorConfig;
|
use Rector\Config\RectorConfig;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
|
||||||
|
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
|
||||||
|
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
||||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||||
|
|
||||||
return RectorConfig::configure()
|
return RectorConfig::configure()
|
||||||
->withPaths([
|
->withPaths([
|
||||||
__DIR__.'/src',
|
__DIR__.'/src',
|
||||||
|
__DIR__.'/tests',
|
||||||
|
])
|
||||||
|
->withSets([
|
||||||
|
PestSetList::CODING_STYLE,
|
||||||
])
|
])
|
||||||
->withSkip([
|
->withSkip([
|
||||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||||
|
__DIR__.'/tests/Fixtures/Arch',
|
||||||
|
__DIR__.'/tests/Fixtures/Suites',
|
||||||
ReturnNeverTypeRector::class,
|
ReturnNeverTypeRector::class,
|
||||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||||
NarrowObjectReturnTypeRector::class,
|
NarrowObjectReturnTypeRector::class,
|
||||||
RemoveParentDelegatingConstructorRector::class,
|
RemoveParentDelegatingConstructorRector::class,
|
||||||
|
RemoveDuplicatedReturnSelfDocblockRector::class,
|
||||||
|
RemoveUselessUnionReturnDocblockRector::class,
|
||||||
|
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
||||||
|
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
||||||
|
],
|
||||||
|
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
||||||
|
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
||||||
|
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
||||||
|
UseToMatchArrayRector::class,
|
||||||
|
// Test fixtures rely on "unused" constructors, params and properties
|
||||||
|
// (resolved via the container or read through reflection), so the
|
||||||
|
// dead-code and return-type rules below must not touch the test suite.
|
||||||
|
RemoveEmptyClassMethodRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
|
RemoveUnusedConstructorParamRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
|
RemoveUnusedPrivatePropertyRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
|
AddArrowFunctionReturnTypeRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->withPreparedSets(
|
->withPreparedSets(
|
||||||
deadCode: true,
|
deadCode: true,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
name: pest-testing
|
||||||
|
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
@php
|
||||||
|
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||||
|
@endphp
|
||||||
|
# Pest Testing 5
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating Tests
|
||||||
|
|
||||||
|
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||||
|
|
||||||
|
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||||
|
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||||
|
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||||
|
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||||
|
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Browser tests: `tests/Browser/` directory.
|
||||||
|
- Do NOT remove tests without approval - these are core application code.
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||||
|
|
||||||
|
@boostsnippet("Basic Pest Test Example", "php")
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||||
|
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||||
|
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||||
|
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||||
|
|
||||||
|
@boostsnippet("Pest Response Assertion", "php")
|
||||||
|
it('returns all', function () {
|
||||||
|
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||||
|
});
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
| Use | Instead of |
|
||||||
|
|-----|------------|
|
||||||
|
| `assertSuccessful()` | `assertStatus(200)` |
|
||||||
|
| `assertNotFound()` | `assertStatus(404)` |
|
||||||
|
| `assertForbidden()` | `assertStatus(403)` |
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||||
|
|
||||||
|
## Datasets
|
||||||
|
|
||||||
|
Use datasets for repetitive tests (validation rules, etc.):
|
||||||
|
|
||||||
|
@boostsnippet("Pest Dataset Example", "php")
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
## Pest 5 Features
|
||||||
|
|
||||||
|
| Feature | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||||
|
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||||
|
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||||
|
| Browser Testing | Full integration tests in real browsers |
|
||||||
|
| Smoke Testing | Validate multiple pages quickly |
|
||||||
|
| Visual Regression | Compare screenshots for visual changes |
|
||||||
|
| Architecture Testing | Enforce code conventions |
|
||||||
|
|
||||||
|
### Tia (Test Impact Analysis)
|
||||||
|
|
||||||
|
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||||
|
|
||||||
|
@boostsnippet("Tia Example", "shell")
|
||||||
|
./vendor/bin/pest --parallel --tia
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||||
|
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||||
|
|
||||||
|
### New Validation Expectations
|
||||||
|
|
||||||
|
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||||
|
|
||||||
|
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||||
|
expect('nuno@pestphp.com')->toBeEmail();
|
||||||
|
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||||
|
expect('192.168.1.1')->toBeIpAddress();
|
||||||
|
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||||
|
expect('example.com')->toBeHostname();
|
||||||
|
expect('example.co.uk')->toBeDomain();
|
||||||
|
expect('Zm9vYmFy')->toBeBase64();
|
||||||
|
expect('deadbeef')->toBeHexadecimal();
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Time-Balanced Sharding
|
||||||
|
|
||||||
|
Distribute tests across CI shards by execution time rather than count:
|
||||||
|
|
||||||
|
@boostsnippet("Pest Sharding Example", "shell")
|
||||||
|
./vendor/bin/pest --update-shards
|
||||||
|
./vendor/bin/pest --shard=1/4
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||||
|
|
||||||
|
### Browser Test Example
|
||||||
|
|
||||||
|
Browser tests run in real browsers for full integration testing:
|
||||||
|
|
||||||
|
- Browser tests live in `tests/Browser/`.
|
||||||
|
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||||
|
- Use `RefreshDatabase` for clean state per test.
|
||||||
|
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||||
|
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||||
|
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging.
|
||||||
|
|
||||||
|
@boostsnippet("Pest Browser Test Example", "php")
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in');
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!');
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Smoke Testing
|
||||||
|
|
||||||
|
Quickly validate multiple pages have no JavaScript errors:
|
||||||
|
|
||||||
|
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Visual Regression Testing
|
||||||
|
|
||||||
|
Capture and compare screenshots to detect visual changes.
|
||||||
|
|
||||||
|
### Architecture Testing
|
||||||
|
|
||||||
|
@boostsnippet("Architecture Test Example", "php")
|
||||||
|
arch('controllers')
|
||||||
|
->expect('App\Http\Controllers')
|
||||||
|
->toExtendNothing()
|
||||||
|
->toHaveSuffix('Controller');
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||||
|
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||||
|
- Forgetting datasets for repetitive validation tests
|
||||||
|
- Deleting tests without approval
|
||||||
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
||||||
'INFO' => ['blue', 'INFO'],
|
'INFO' => ['blue', 'INFO'],
|
||||||
'ERROR' => ['red', 'ERROR'],
|
'ERROR' => ['red', 'ERROR'],
|
||||||
|
'WARN' => ['yellow', 'WARN'],
|
||||||
|
'SUCCESS' => ['green', 'SUCCESS'],
|
||||||
};
|
};
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray mr-1">- </span>
|
<span class="text-gray mr-1">- </span>
|
||||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ abstract class AbstractPreset // @pest-arch-ignore-line
|
|||||||
/**
|
/**
|
||||||
* Runs the given callback for each namespace.
|
* 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
|
final public function eachUserNamespace(callable ...$callbacks): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ final class Laravel extends AbstractPreset
|
|||||||
->toHaveSuffix('Request');
|
->toHaveSuffix('Request');
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Http\Requests')
|
$this->expectations[] = expect('App\Http\Requests')
|
||||||
|
->classes()
|
||||||
->toExtend('Illuminate\Foundation\Http\FormRequest');
|
->toExtend('Illuminate\Foundation\Http\FormRequest');
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Http\Requests')
|
$this->expectations[] = expect('App\Http\Requests')
|
||||||
@@ -118,6 +119,7 @@ final class Laravel extends AbstractPreset
|
|||||||
->toHaveMethod('handle');
|
->toHaveMethod('handle');
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Notifications')
|
$this->expectations[] = expect('App\Notifications')
|
||||||
|
->classes()
|
||||||
->toExtend('Illuminate\Notifications\Notification');
|
->toExtend('Illuminate\Notifications\Notification');
|
||||||
|
|
||||||
$this->expectations[] = expect('App')
|
$this->expectations[] = expect('App')
|
||||||
@@ -128,6 +130,7 @@ final class Laravel extends AbstractPreset
|
|||||||
->toHaveSuffix('ServiceProvider');
|
->toHaveSuffix('ServiceProvider');
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Providers')
|
$this->expectations[] = expect('App\Providers')
|
||||||
|
->classes()
|
||||||
->toExtend('Illuminate\Support\ServiceProvider');
|
->toExtend('Illuminate\Support\ServiceProvider');
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Providers')
|
$this->expectations[] = expect('App\Providers')
|
||||||
@@ -150,7 +153,7 @@ final class Laravel extends AbstractPreset
|
|||||||
->toHaveSuffix('Controller');
|
->toHaveSuffix('Controller');
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Http')
|
$this->expectations[] = expect('App\Http')
|
||||||
->toOnlyBeUsedIn('App\Http');
|
->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
|
||||||
|
|
||||||
$this->expectations[] = expect('App\Http\Controllers')
|
$this->expectations[] = expect('App\Http\Controllers')
|
||||||
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
|
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ final class BootOverrides implements Bootstrapper
|
|||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
public const array FILES = [
|
public const array FILES = [
|
||||||
|
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||||
'Runner/Filter/NameFilterIterator.php',
|
'Runner/Filter/NameFilterIterator.php',
|
||||||
'Runner/ResultCache/DefaultResultCache.php',
|
'Runner/ResultCache/DefaultResultCache.php',
|
||||||
'Runner/TestSuiteLoader.php',
|
'Runner/TestSuiteLoader.php',
|
||||||
|
'Runner/TestSuiteSorter.php',
|
||||||
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
||||||
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
||||||
'TextUI/TestSuiteFilterProcessor.php',
|
'TextUI/TestSuiteFilterProcessor.php',
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Bootstrappers;
|
||||||
|
|
||||||
|
use Pest\Contracts\Bootstrapper;
|
||||||
|
use PHPUnit\TextUI\Configuration\Builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class BootPhpUnitConfiguration implements Bootstrapper
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
(new Builder)->build(['pest']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,23 @@ final readonly class BootSubscribers implements Bootstrapper
|
|||||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||||
Subscribers\EnsureTeamCityEnabled::class,
|
Subscribers\EnsureTeamCityEnabled::class,
|
||||||
|
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
||||||
|
Subscribers\EnsureTiaStarts::class,
|
||||||
|
Subscribers\EnsureTiaEnds::class,
|
||||||
|
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnNoticeTriggered::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnPhpNoticeTriggered::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnDeprecationTriggered::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnPhpDeprecationTriggered::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnWarningTriggered::class,
|
||||||
|
Subscribers\EnsureTiaResultIsRecordedOnPhpWarningTriggered::class,
|
||||||
|
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -66,6 +66,6 @@ trait Pipeable
|
|||||||
*/
|
*/
|
||||||
private function pipes(string $name, object $context, string $scope): array
|
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] ?? []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-6
@@ -7,14 +7,24 @@ namespace Pest\Concerns;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
|
use Pest\Plugins\Tia\Collectors;
|
||||||
|
use Pest\Plugins\Tia\Enums\ReplayType;
|
||||||
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Preset;
|
use Pest\Preset;
|
||||||
use Pest\Support\ChainableClosure;
|
use Pest\Support\ChainableClosure;
|
||||||
|
use Pest\Support\Container;
|
||||||
use Pest\Support\ExceptionTrace;
|
use Pest\Support\ExceptionTrace;
|
||||||
use Pest\Support\Reflection;
|
use Pest\Support\Reflection;
|
||||||
use Pest\Support\Shell;
|
use Pest\Support\Shell;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
use PHPUnit\Framework\AssertionFailedError;
|
||||||
use PHPUnit\Framework\Attributes\PostCondition;
|
use PHPUnit\Framework\Attributes\PostCondition;
|
||||||
|
use PHPUnit\Framework\IncompleteTest;
|
||||||
|
use PHPUnit\Framework\SkippedTest;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||||
|
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||||
use ReflectionException;
|
use ReflectionException;
|
||||||
use ReflectionFunction;
|
use ReflectionFunction;
|
||||||
use ReflectionParameter;
|
use ReflectionParameter;
|
||||||
@@ -73,6 +83,17 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
public bool $__ran = false;
|
public bool $__ran = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active replay mode for this test, set in `setUp()` and checked
|
||||||
|
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
||||||
|
*/
|
||||||
|
private ReplayType $__replay = ReplayType::None;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cached assertion count to replay, captured when entering replay mode.
|
||||||
|
*/
|
||||||
|
private int $__replayAssertions = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The test's test closure.
|
* The test's test closure.
|
||||||
*/
|
*/
|
||||||
@@ -129,7 +150,7 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
public function __addBeforeAll(?Closure $hook): void
|
public function __addBeforeAll(?Closure $hook): void
|
||||||
{
|
{
|
||||||
if (! $hook instanceof \Closure) {
|
if (! $hook instanceof Closure) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +164,7 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
public function __addAfterAll(?Closure $hook): void
|
public function __addAfterAll(?Closure $hook): void
|
||||||
{
|
{
|
||||||
if (! $hook instanceof \Closure) {
|
if (! $hook instanceof Closure) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +194,7 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __addHook(string $property, ?Closure $hook): void
|
private function __addHook(string $property, ?Closure $hook): void
|
||||||
{
|
{
|
||||||
if (! $hook instanceof \Closure) {
|
if (! $hook instanceof Closure) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +278,37 @@ trait Testable
|
|||||||
self::$__latestIssues = $method->issues;
|
self::$__latestIssues = $method->issues;
|
||||||
self::$__latestPrs = $method->prs;
|
self::$__latestPrs = $method->prs;
|
||||||
|
|
||||||
|
/** @var Tia $tia */
|
||||||
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
|
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||||
|
$replay = ReplayType::fromStatus($status);
|
||||||
|
|
||||||
|
if ($replay !== ReplayType::None) {
|
||||||
|
assert($status !== null);
|
||||||
|
|
||||||
|
$this->__replay = $replay;
|
||||||
|
|
||||||
|
match ($replay) {
|
||||||
|
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||||
|
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||||
|
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
|
||||||
|
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recorder = Container::getInstance()->get(Recorder::class);
|
||||||
|
assert($recorder instanceof Recorder);
|
||||||
|
|
||||||
|
if ($recorder->isActive()) {
|
||||||
|
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||||
|
}
|
||||||
|
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
Collectors::armAll($recorder);
|
||||||
|
|
||||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||||
|
|
||||||
if ($this->__beforeEach instanceof Closure) {
|
if ($this->__beforeEach instanceof Closure) {
|
||||||
@@ -268,6 +318,13 @@ trait Testable
|
|||||||
$this->__callClosure($beforeEach, $arguments);
|
$this->__callClosure($beforeEach, $arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||||
|
{
|
||||||
|
$this->__replay = $replay;
|
||||||
|
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||||
|
$this->__ran = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize test case properties from TestSuite.
|
* Initialize test case properties from TestSuite.
|
||||||
*/
|
*/
|
||||||
@@ -300,6 +357,12 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
protected function tearDown(...$arguments): void
|
protected function tearDown(...$arguments): void
|
||||||
{
|
{
|
||||||
|
if ($this->__replay !== ReplayType::None) {
|
||||||
|
TestSuite::getInstance()->test = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||||
|
|
||||||
if ($this->__afterEach instanceof Closure) {
|
if ($this->__afterEach instanceof Closure) {
|
||||||
@@ -325,12 +388,108 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __runTest(Closure $closure, ...$args): mixed
|
private function __runTest(Closure $closure, ...$args): mixed
|
||||||
{
|
{
|
||||||
|
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
|
||||||
|
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
||||||
|
$this->expectNotToPerformAssertions();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addToAssertionCount($this->__replayAssertions);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$arguments = $this->__resolveTestArguments($args);
|
$arguments = $this->__resolveTestArguments($args);
|
||||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||||
|
|
||||||
|
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||||
|
|
||||||
|
if ($method->flakyTries === null) {
|
||||||
return $this->__callClosure($closure, $arguments);
|
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});
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||||
|
|
||||||
|
if ($outputBuffer->hasExpectation()) {
|
||||||
|
ob_clean();
|
||||||
|
|
||||||
|
Closure::bind(function (): void {
|
||||||
|
$this->expectedString = null;
|
||||||
|
$this->expectedRegularExpression = null;
|
||||||
|
}, $outputBuffer, OutputBuffer::class)();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $lastException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the given exception matches PHPUnit's expected exception.
|
||||||
|
*/
|
||||||
|
private function __isExpectedException(Throwable $e): bool
|
||||||
|
{
|
||||||
|
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||||
|
|
||||||
|
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||||
|
|
||||||
|
$expectedClass = $read('expectedException');
|
||||||
|
|
||||||
|
if ($expectedClass !== null) {
|
||||||
|
return $e instanceof $expectedClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedMessage = $read('expectedMessage');
|
||||||
|
|
||||||
|
if ($expectedMessage !== null) {
|
||||||
|
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||||
|
|
||||||
|
if ($expectedMessageRegex !== null) {
|
||||||
|
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedCode = $read('expectedCode');
|
||||||
|
|
||||||
|
if ($expectedCode !== null) {
|
||||||
|
return $e->getCode() === $expectedCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the passed arguments. Any Closures will be bound to the testcase and resolved.
|
* Resolve the passed arguments. Any Closures will be bound to the testcase and resolved.
|
||||||
*
|
*
|
||||||
@@ -350,7 +509,8 @@ trait Testable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
|
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
|
||||||
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest));
|
$testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
|
||||||
|
$testParameterTypes = array_values($testParameterTypesByName);
|
||||||
|
|
||||||
if (count($arguments) !== 1) {
|
if (count($arguments) !== 1) {
|
||||||
foreach ($arguments as $argumentIndex => $argumentValue) {
|
foreach ($arguments as $argumentIndex => $argumentValue) {
|
||||||
@@ -358,7 +518,11 @@ trait Testable
|
|||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +548,7 @@ trait Testable
|
|||||||
return [$boundDatasetResult];
|
return [$boundDatasetResult];
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values($boundDatasetResult);
|
return $boundDatasetResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+19
-3
@@ -33,7 +33,7 @@ final readonly class Configuration
|
|||||||
*/
|
*/
|
||||||
public function in(string ...$targets): UsesCall
|
public function in(string ...$targets): UsesCall
|
||||||
{
|
{
|
||||||
return (new UsesCall($this->filename, []))->in(...$targets);
|
return new UsesCall($this->filename, [])->in(...$targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +60,7 @@ final readonly class Configuration
|
|||||||
*/
|
*/
|
||||||
public function group(string ...$groups): UsesCall
|
public function group(string ...$groups): UsesCall
|
||||||
{
|
{
|
||||||
return (new UsesCall($this->filename, []))->group(...$groups);
|
return new UsesCall($this->filename, [])->group(...$groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +68,7 @@ final readonly class Configuration
|
|||||||
*/
|
*/
|
||||||
public function only(): void
|
public function only(): void
|
||||||
{
|
{
|
||||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +119,22 @@ final readonly class Configuration
|
|||||||
return new Browser\Configuration;
|
return new Browser\Configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the evals configuration.
|
||||||
|
*/
|
||||||
|
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
||||||
|
{
|
||||||
|
return new Evals\Configuration; // @phpstan-ignore-line
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the TIA (Test Impact Analysis) configuration.
|
||||||
|
*/
|
||||||
|
public function tia(): Plugins\Tia\Configuration
|
||||||
|
{
|
||||||
|
return new Plugins\Tia\Configuration;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxies calls to the uses method.
|
* Proxies calls to the uses method.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -59,12 +59,15 @@ final class Project
|
|||||||
/**
|
/**
|
||||||
* Sets the test project to GitLab.
|
* Sets the test project to GitLab.
|
||||||
*/
|
*/
|
||||||
public function gitlab(string $project): self
|
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
||||||
{
|
{
|
||||||
$this->issues = "https://gitlab.com/{$project}/issues/%s";
|
// Simple way to ensure only the host is used
|
||||||
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
|
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
||||||
|
|
||||||
$this->assignees = 'https://gitlab.com/%s';
|
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
||||||
|
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
|
||||||
|
|
||||||
|
$this->assignees = "https://{$hostname}/%s";
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface Restarter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
public function maybeRestart(string $projectRoot, array $arguments): void;
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use Pest\Support\View;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $headline,
|
||||||
|
private readonly string $hint,
|
||||||
|
private readonly bool $hasAnchor = false,
|
||||||
|
) {
|
||||||
|
parent::__construct($headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
View::renderUsing($output);
|
||||||
|
|
||||||
|
if (! $this->hasAnchor) {
|
||||||
|
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
||||||
|
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||||
|
$output->writeln('');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderChild($output, $this->headline);
|
||||||
|
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||||
|
$output->writeln('');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderChild(OutputInterface $output, string $text): void
|
||||||
|
{
|
||||||
|
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name, string $scope)
|
public function __construct(string $name, string $scope)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name)
|
public function __construct(string $name)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class DatasetProviderError extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(Throwable $previous)
|
||||||
|
{
|
||||||
|
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates a new Exception instance for the given class name.
|
||||||
|
*/
|
||||||
|
public static function fromClassName(string $filename, string $className): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||||
|
$filename,
|
||||||
|
$className,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Exception instance for the given namespace.
|
||||||
|
*/
|
||||||
|
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||||
|
$filename,
|
||||||
|
$namespace,
|
||||||
|
$part,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class MissingDependency extends InvalidArgumentException implements Except
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $feature, string $dependency)
|
public function __construct(string $feature, string $dependency)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency));
|
parent::__construct(sprintf('The feature [%s] requires [%s].', $feature, $dependency));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName, string $description)
|
public function __construct(string $fileName, string $description)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $testCaseClass)
|
public function __construct(string $testCaseClass)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName)
|
public function __construct(string $fileName)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TiaRequiresCommit extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
'Tia mode requires a repository with at least one commit, so the baseline it records can be anchored to a revision.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires at least one commit.',
|
||||||
|
'',
|
||||||
|
' A baseline is anchored to the revision it was recorded at, and this repository',
|
||||||
|
' has none yet, so there is nothing to record against and nothing to compare a',
|
||||||
|
' later run to.',
|
||||||
|
'',
|
||||||
|
' Commit once, then run again:',
|
||||||
|
'',
|
||||||
|
' <fg=yellow>git add . && git commit -m "Initial commit"</>',
|
||||||
|
'',
|
||||||
|
' Runs without <fg=yellow>--tia</> are unaffected.',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TiaRequiresDefaultBranch extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
'Tia mode could not determine the default branch every other branch falls back to reading.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> ERROR </> Tia mode could not determine the default branch.',
|
||||||
|
'',
|
||||||
|
' It is the branch whose baseline every other branch falls back to reading, and',
|
||||||
|
' nothing in this checkout names it: the repository has a remote, but no',
|
||||||
|
' <fg=yellow>origin/HEAD</>, and no CI provider stated it either. Guessing would re-run the',
|
||||||
|
' whole suite on every new branch while reporting it as a cache hit.',
|
||||||
|
'',
|
||||||
|
' Name the branch in <fg=yellow>tests/Pest.php</>:',
|
||||||
|
'',
|
||||||
|
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
||||||
|
'',
|
||||||
|
' Or let git answer, once per clone:',
|
||||||
|
'',
|
||||||
|
' <fg=yellow>git remote set-head origin --auto</>',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $className, string $filename)
|
||||||
|
{
|
||||||
|
parent::__construct(sprintf(
|
||||||
|
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||||
|
$className,
|
||||||
|
$filename,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
|
||||||
|
'',
|
||||||
|
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
|
||||||
|
sprintf(' in <fg=gray>%s</>.', $this->file),
|
||||||
|
'',
|
||||||
|
' Convert it to a Pest test, or run without Tia.',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TiaRequiresRemote extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
'Tia mode requires a repository with a remote, so the default branch every other branch falls back to reading can be resolved.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires a repository with a remote.',
|
||||||
|
'',
|
||||||
|
' Without one there is no way to tell which branch is the default — the branch',
|
||||||
|
' whose baseline every other branch falls back to reading — so the first run on',
|
||||||
|
' each new branch would silently re-run the whole suite.',
|
||||||
|
'',
|
||||||
|
' Add a remote, or name the branch yourself in <fg=yellow>tests/Pest.php</>:',
|
||||||
|
'',
|
||||||
|
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $subdirectoryPrefix)
|
||||||
|
{
|
||||||
|
parent::__construct(sprintf(
|
||||||
|
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||||
|
$this->subdirectoryPrefix,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
|
||||||
|
'',
|
||||||
|
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
|
||||||
|
'',
|
||||||
|
' Give the project its own git repository to use Tia.',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-20
@@ -18,6 +18,7 @@ use Pest\Arch\Expectations\ToOnlyUse;
|
|||||||
use Pest\Arch\Expectations\ToUse;
|
use Pest\Arch\Expectations\ToUse;
|
||||||
use Pest\Arch\Expectations\ToUseNothing;
|
use Pest\Arch\Expectations\ToUseNothing;
|
||||||
use Pest\Arch\PendingArchExpectation;
|
use Pest\Arch\PendingArchExpectation;
|
||||||
|
use Pest\Arch\Support\Composer;
|
||||||
use Pest\Arch\Support\FileLineFinder;
|
use Pest\Arch\Support\FileLineFinder;
|
||||||
use Pest\Concerns\Extendable;
|
use Pest\Concerns\Extendable;
|
||||||
use Pest\Concerns\Pipeable;
|
use Pest\Concerns\Pipeable;
|
||||||
@@ -111,7 +112,7 @@ final class Expectation
|
|||||||
if (function_exists('dump')) {
|
if (function_exists('dump')) {
|
||||||
dump($this->value, ...$arguments);
|
dump($this->value, ...$arguments);
|
||||||
} else {
|
} else {
|
||||||
var_dump($this->value);
|
var_dump($this->value, ...$arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -119,16 +120,22 @@ final class Expectation
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Dump the expectation value and end the script.
|
* Dump the expectation value and end the script.
|
||||||
*
|
|
||||||
* @return never
|
|
||||||
*/
|
*/
|
||||||
public function dd(mixed ...$arguments): void
|
public function dd(mixed ...$arguments): never
|
||||||
{
|
{
|
||||||
if (function_exists('dd')) {
|
if (function_exists('dd')) {
|
||||||
dd($this->value, ...$arguments);
|
dd($this->value, ...$arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
var_dump($this->value);
|
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||||
|
ob_start();
|
||||||
|
var_dump($this->value, ...$arguments);
|
||||||
|
$output = ob_get_clean();
|
||||||
|
|
||||||
|
throw new ExpectationFailedException($output);
|
||||||
|
}
|
||||||
|
|
||||||
|
var_dump($this->value, ...$arguments);
|
||||||
|
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -136,7 +143,7 @@ final class Expectation
|
|||||||
/**
|
/**
|
||||||
* Dump the expectation value when the result of the condition is truthy.
|
* 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>
|
* @return self<TValue>
|
||||||
*/
|
*/
|
||||||
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
|
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
|
||||||
@@ -153,7 +160,7 @@ final class Expectation
|
|||||||
/**
|
/**
|
||||||
* Dump the expectation value when the result of the condition is falsy.
|
* 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>
|
* @return self<TValue>
|
||||||
*/
|
*/
|
||||||
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
|
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
|
||||||
@@ -237,7 +244,7 @@ final class Expectation
|
|||||||
if ($callbacks[$index] instanceof Closure) {
|
if ($callbacks[$index] instanceof Closure) {
|
||||||
$callbacks[$index](new self($value), new self($key));
|
$callbacks[$index](new self($value), new self($key));
|
||||||
} else {
|
} else {
|
||||||
(new self($value))->toEqual($callbacks[$index]);
|
new self($value)->toEqual($callbacks[$index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||||
@@ -669,6 +676,41 @@ final class Expectation
|
|||||||
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
|
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.
|
* Asserts that the given expectation target is enum.
|
||||||
*/
|
*/
|
||||||
@@ -783,7 +825,22 @@ final class Expectation
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -864,15 +921,7 @@ final class Expectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$this,
|
$this,
|
||||||
function (ObjectDescription $object) use ($interfaces): bool {
|
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||||
foreach ($interfaces as $interface) {
|
|
||||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"to implement '".implode("', '", $interfaces)."'",
|
"to implement '".implode("', '", $interfaces)."'",
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
@@ -1087,8 +1136,8 @@ final class Expectation
|
|||||||
$this,
|
$this,
|
||||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||||
&& $object->reflectionClass->isEnum()
|
&& $object->reflectionClass->isEnum()
|
||||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||||
'to be '.$backingType.' backed enum',
|
'to be '.$backingType.' backed enum',
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
|
|||||||
* Dynamically calls methods on the class with the given arguments.
|
* Dynamically calls methods on the class with the given arguments.
|
||||||
*
|
*
|
||||||
* @param array<int, mixed> $arguments
|
* @param array<int, mixed> $arguments
|
||||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
* @return self<TOriginalValue, mixed>
|
||||||
*/
|
*/
|
||||||
public function __call(string $name, array $arguments): self
|
public function __call(string $name, array $arguments): self
|
||||||
{
|
{
|
||||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
|||||||
/**
|
/**
|
||||||
* Accesses properties in the value or in the expectation.
|
* Accesses properties in the value or in the expectation.
|
||||||
*
|
*
|
||||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
* @return self<TOriginalValue, mixed>
|
||||||
*/
|
*/
|
||||||
public function __get(string $name): self
|
public function __get(string $name): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$original,
|
$original,
|
||||||
function (ObjectDescription $object) use ($traits): bool {
|
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||||
foreach ($traits as $trait) {
|
|
||||||
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"not to use traits '".implode("', '", $traits)."'",
|
"not to use traits '".implode("', '", $traits)."'",
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$original,
|
$original,
|
||||||
function (ObjectDescription $object) use ($interfaces): bool {
|
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||||
foreach ($interfaces as $interface) {
|
|
||||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"not to implement '".implode("', '", $interfaces)."'",
|
"not to implement '".implode("', '", $interfaces)."'",
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
$exporter = Exporter::default();
|
$exporter = Exporter::default();
|
||||||
|
|
||||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
|
||||||
|
|
||||||
throw new ExpectationFailedException(sprintf(
|
throw new ExpectationFailedException(sprintf(
|
||||||
'Expecting %s not %s %s.',
|
'Expecting %s not %s %s.',
|
||||||
$toString($this->original->value),
|
$exporter->shortenedExport($this->original->value),
|
||||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
|
|||||||
$original,
|
$original,
|
||||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||||
|| ! $object->reflectionClass->isEnum()
|
|| ! $object->reflectionClass->isEnum()
|
||||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||||
'not to be '.$backingType.' backed enum',
|
'not to be '.$backingType.' backed enum',
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
|||||||
use Pest\Contracts\HasPrintableTestCaseName;
|
use Pest\Contracts\HasPrintableTestCaseName;
|
||||||
use Pest\Evaluators\Attributes;
|
use Pest\Evaluators\Attributes;
|
||||||
use Pest\Exceptions\DatasetMissing;
|
use Pest\Exceptions\DatasetMissing;
|
||||||
|
use Pest\Exceptions\InvalidTestClassName;
|
||||||
use Pest\Exceptions\ShouldNotHappen;
|
use Pest\Exceptions\ShouldNotHappen;
|
||||||
use Pest\Exceptions\TestAlreadyExist;
|
use Pest\Exceptions\TestAlreadyExist;
|
||||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||||
@@ -17,6 +18,7 @@ use Pest\Factories\Concerns\HigherOrderable;
|
|||||||
use Pest\Support\Reflection;
|
use Pest\Support\Reflection;
|
||||||
use Pest\Support\Str;
|
use Pest\Support\Str;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -58,6 +60,11 @@ final class TestCaseFactory
|
|||||||
Concerns\Expectable::class,
|
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.
|
* Creates a new Factory instance.
|
||||||
*/
|
*/
|
||||||
@@ -88,7 +95,8 @@ final class TestCaseFactory
|
|||||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
$realpath = (string) realpath($filename);
|
||||||
|
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||||
$rootPath = TestSuite::getInstance()->rootPath;
|
$rootPath = TestSuite::getInstance()->rootPath;
|
||||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||||
|
|
||||||
@@ -110,8 +118,8 @@ final class TestCaseFactory
|
|||||||
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
|
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
|
||||||
// Remove escaped quote sequences (maintain namespace)
|
// Remove escaped quote sequences (maintain namespace)
|
||||||
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
|
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
|
||||||
// Limit to A-Z, a-z, 0-9, '_', '-'.
|
// Limit to Unicode letters and numbers.
|
||||||
$relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath);
|
$relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath);
|
||||||
|
|
||||||
$classFQN = 'P\\'.$relativePath;
|
$classFQN = 'P\\'.$relativePath;
|
||||||
|
|
||||||
@@ -126,16 +134,26 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
$partsFQN = explode('\\', $classFQN);
|
$partsFQN = explode('\\', $classFQN);
|
||||||
$className = array_pop($partsFQN);
|
$className = array_pop($partsFQN);
|
||||||
$namespace = implode('\\', $partsFQN);
|
$namespace = $this->namespace ?? implode('\\', $partsFQN);
|
||||||
$baseClass = sprintf('\%s', $this->class);
|
$baseClass = sprintf('\%s', $this->class);
|
||||||
|
|
||||||
if (trim($className) === '') {
|
if (trim($className) === '') {
|
||||||
$className = 'InvalidTestName'.Str::random();
|
$className = 'InvalidTestName'.Str::random();
|
||||||
|
} elseif (! Str::isValidClassName($className)) {
|
||||||
|
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->namespace === null) {
|
||||||
|
foreach ($partsFQN as $partFQN) {
|
||||||
|
if (! Str::isValidIdentifier($partFQN)) {
|
||||||
|
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->attributes = [
|
$this->attributes = [
|
||||||
new Attribute(
|
new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\TestDox::class,
|
TestDox::class,
|
||||||
[$this->filename],
|
[$this->filename],
|
||||||
),
|
),
|
||||||
...$this->attributes,
|
...$this->attributes,
|
||||||
@@ -143,6 +161,8 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
$attributesCode = Attributes::code($this->attributes);
|
$attributesCode = Attributes::code($this->attributes);
|
||||||
|
|
||||||
|
$filenameLiteral = var_export($realpath, true);
|
||||||
|
|
||||||
$methodsCode = implode('', array_map(
|
$methodsCode = implode('', array_map(
|
||||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||||
$methods
|
$methods
|
||||||
@@ -152,6 +172,7 @@ final class TestCaseFactory
|
|||||||
$classCode = <<<PHP
|
$classCode = <<<PHP
|
||||||
namespace $namespace;
|
namespace $namespace;
|
||||||
|
|
||||||
|
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||||
use Pest\TestSuite as __PestTestSuite;
|
use Pest\TestSuite as __PestTestSuite;
|
||||||
|
|
||||||
@@ -160,7 +181,7 @@ final class TestCaseFactory
|
|||||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||||
$traitsCode
|
$traitsCode
|
||||||
|
|
||||||
private static \$__filename = '$filename';
|
public static \$__filename = $filenameLiteral;
|
||||||
|
|
||||||
$methodsCode
|
$methodsCode
|
||||||
}
|
}
|
||||||
@@ -191,7 +212,7 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$method->closure instanceof \Closure &&
|
$method->closure instanceof \Closure &&
|
||||||
(new \ReflectionFunction($method->closure))->isStatic()
|
new \ReflectionFunction($method->closure)->isStatic()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
throw new TestClosureMustNotBeStatic($method);
|
throw new TestClosureMustNotBeStatic($method);
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ use Pest\Evaluators\Attributes;
|
|||||||
use Pest\Exceptions\ShouldNotHappen;
|
use Pest\Exceptions\ShouldNotHappen;
|
||||||
use Pest\Factories\Concerns\HigherOrderable;
|
use Pest\Factories\Concerns\HigherOrderable;
|
||||||
use Pest\Repositories\DatasetsRepository;
|
use Pest\Repositories\DatasetsRepository;
|
||||||
|
use Pest\Support\Description;
|
||||||
use Pest\Support\Str;
|
use Pest\Support\Str;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\Assert;
|
use PHPUnit\Framework\Assert;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Depends;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,7 +36,7 @@ final class TestCaseMethodFactory
|
|||||||
/**
|
/**
|
||||||
* The test's describing, if any.
|
* The test's describing, if any.
|
||||||
*
|
*
|
||||||
* @var array<int, \Pest\Support\Description>
|
* @var array<int, Description>
|
||||||
*/
|
*/
|
||||||
public array $describing = [];
|
public array $describing = [];
|
||||||
|
|
||||||
@@ -46,6 +50,11 @@ final class TestCaseMethodFactory
|
|||||||
*/
|
*/
|
||||||
public int $repetitions = 1;
|
public int $repetitions = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The test's number of flaky retry tries.
|
||||||
|
*/
|
||||||
|
public ?int $flakyTries = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the test is a "todo".
|
* Determines if the test is a "todo".
|
||||||
*/
|
*/
|
||||||
@@ -192,11 +201,11 @@ final class TestCaseMethodFactory
|
|||||||
|
|
||||||
$this->attributes = [
|
$this->attributes = [
|
||||||
new Attribute(
|
new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\Test::class,
|
Test::class,
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
new Attribute(
|
new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\TestDox::class,
|
TestDox::class,
|
||||||
[str_replace('*/', '{@*}', $this->description)],
|
[str_replace('*/', '{@*}', $this->description)],
|
||||||
),
|
),
|
||||||
...$this->attributes,
|
...$this->attributes,
|
||||||
@@ -206,7 +215,7 @@ final class TestCaseMethodFactory
|
|||||||
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
|
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
|
||||||
|
|
||||||
$this->attributes[] = new Attribute(
|
$this->attributes[] = new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\Depends::class,
|
Depends::class,
|
||||||
[$depend],
|
[$depend],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -226,6 +235,10 @@ final class TestCaseMethodFactory
|
|||||||
$attributesCode
|
$attributesCode
|
||||||
public function $methodName(...\$arguments)
|
public function $methodName(...\$arguments)
|
||||||
{
|
{
|
||||||
|
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||||
|
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||||
|
}
|
||||||
|
|
||||||
return \$this->__runTest(
|
return \$this->__runTest(
|
||||||
\$this->__test,
|
\$this->__test,
|
||||||
...\$arguments,
|
...\$arguments,
|
||||||
@@ -252,7 +265,11 @@ final class TestCaseMethodFactory
|
|||||||
|
|
||||||
public static function $dataProviderName()
|
public static function $dataProviderName()
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||||
|
} catch (\Throwable \$throwable) {
|
||||||
|
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EOF;
|
EOF;
|
||||||
|
|||||||
+19
-37
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
use Pest\Browser\Api\ArrayablePendingAwaitablePage;
|
use Pest\Browser\Api\ArrayablePendingAwaitablePage;
|
||||||
use Pest\Browser\Api\PendingAwaitablePage;
|
use Pest\Browser\Api\PendingAwaitablePage;
|
||||||
use Pest\Concerns\Expectable;
|
|
||||||
use Pest\Configuration;
|
use Pest\Configuration;
|
||||||
use Pest\Exceptions\AfterAllWithinDescribe;
|
use Pest\Exceptions\AfterAllWithinDescribe;
|
||||||
use Pest\Exceptions\BeforeAllWithinDescribe;
|
use Pest\Exceptions\BeforeAllWithinDescribe;
|
||||||
@@ -48,7 +47,7 @@ if (! function_exists('beforeAll')) {
|
|||||||
function beforeAll(Closure $closure): void
|
function beforeAll(Closure $closure): void
|
||||||
{
|
{
|
||||||
if (DescribeCall::describing() !== []) {
|
if (DescribeCall::describing() !== []) {
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
throw new BeforeAllWithinDescribe($filename);
|
throw new BeforeAllWithinDescribe($filename);
|
||||||
}
|
}
|
||||||
@@ -61,13 +60,11 @@ if (! function_exists('beforeEach')) {
|
|||||||
/**
|
/**
|
||||||
* Runs the given closure before each test in the current file.
|
* Runs the given closure before each test in the current file.
|
||||||
*
|
*
|
||||||
* @param-closure-this TestCase $closure
|
* @param-closure-this TestCall $closure
|
||||||
*
|
|
||||||
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
|
|
||||||
*/
|
*/
|
||||||
function beforeEach(?Closure $closure = null): BeforeEachCall
|
function beforeEach(?Closure $closure = null): BeforeEachCall
|
||||||
{
|
{
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
|
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
|
||||||
}
|
}
|
||||||
@@ -92,8 +89,6 @@ if (! function_exists('describe')) {
|
|||||||
* Adds the given closure as a group of tests. The first argument
|
* Adds the given closure as a group of tests. The first argument
|
||||||
* is the group description; the second argument is a closure
|
* is the group description; the second argument is a closure
|
||||||
* that contains the group tests.
|
* that contains the group tests.
|
||||||
*
|
|
||||||
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
|
|
||||||
*/
|
*/
|
||||||
function describe(string $description, Closure $tests): DescribeCall
|
function describe(string $description, Closure $tests): DescribeCall
|
||||||
{
|
{
|
||||||
@@ -112,7 +107,7 @@ if (! function_exists('uses')) {
|
|||||||
*/
|
*/
|
||||||
function uses(string ...$classAndTraits): UsesCall
|
function uses(string ...$classAndTraits): UsesCall
|
||||||
{
|
{
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
return new UsesCall($filename, array_values($classAndTraits));
|
return new UsesCall($filename, array_values($classAndTraits));
|
||||||
}
|
}
|
||||||
@@ -124,7 +119,7 @@ if (! function_exists('pest')) {
|
|||||||
*/
|
*/
|
||||||
function pest(): Configuration
|
function pest(): Configuration
|
||||||
{
|
{
|
||||||
return new Configuration(Backtrace::file());
|
return new Configuration(Backtrace::testFile());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,13 +129,13 @@ if (! function_exists('test')) {
|
|||||||
* is the test description; the second argument is
|
* is the test description; the second argument is
|
||||||
* a closure that contains the test expectations.
|
* 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
|
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);
|
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,34 +151,23 @@ if (! function_exists('it')) {
|
|||||||
* is the test description; the second argument is
|
* is the test description; the second argument is
|
||||||
* a closure that contains the test expectations.
|
* a closure that contains the test expectations.
|
||||||
*
|
*
|
||||||
* @param-closure-this TestCase $closure
|
* @param-closure-this TestCall $closure
|
||||||
*
|
|
||||||
* @return Expectable|TestCall|TestCase|mixed
|
|
||||||
*/
|
*/
|
||||||
function it(string $description, ?Closure $closure = null): TestCall
|
function it(string $description, ?Closure $closure = null): TestCall
|
||||||
{
|
{
|
||||||
$description = sprintf('it %s', $description);
|
$description = sprintf('it %s', $description);
|
||||||
|
|
||||||
/** @var TestCall $test */
|
return test($description, $closure);
|
||||||
$test = test($description, $closure);
|
|
||||||
|
|
||||||
return $test;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! function_exists('todo')) {
|
if (! function_exists('todo')) {
|
||||||
/**
|
/**
|
||||||
* Creates a new test that is marked as "todo".
|
* Creates a new test that is marked as "todo".
|
||||||
*
|
|
||||||
* @return Expectable|TestCall|TestCase|mixed
|
|
||||||
*/
|
*/
|
||||||
function todo(string $description): TestCall
|
function todo(string $description): TestCall
|
||||||
{
|
{
|
||||||
$test = test($description);
|
return test($description)->todo();
|
||||||
|
|
||||||
assert($test instanceof TestCall);
|
|
||||||
|
|
||||||
return $test->todo();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,13 +175,11 @@ if (! function_exists('afterEach')) {
|
|||||||
/**
|
/**
|
||||||
* Runs the given closure after each test in the current file.
|
* Runs the given closure after each test in the current file.
|
||||||
*
|
*
|
||||||
* @param-closure-this TestCase $closure
|
* @param-closure-this TestCall $closure
|
||||||
*
|
|
||||||
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|TestCall|mixed
|
|
||||||
*/
|
*/
|
||||||
function afterEach(?Closure $closure = null): AfterEachCall
|
function afterEach(?Closure $closure = null): AfterEachCall
|
||||||
{
|
{
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
|
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
|
||||||
}
|
}
|
||||||
@@ -210,7 +192,7 @@ if (! function_exists('afterAll')) {
|
|||||||
function afterAll(Closure $closure): void
|
function afterAll(Closure $closure): void
|
||||||
{
|
{
|
||||||
if (DescribeCall::describing() !== []) {
|
if (DescribeCall::describing() !== []) {
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
throw new AfterAllWithinDescribe($filename);
|
throw new AfterAllWithinDescribe($filename);
|
||||||
}
|
}
|
||||||
@@ -227,7 +209,7 @@ if (! function_exists('covers')) {
|
|||||||
*/
|
*/
|
||||||
function covers(array|string ...$classesOrFunctions): void
|
function covers(array|string ...$classesOrFunctions): void
|
||||||
{
|
{
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
||||||
|
|
||||||
@@ -236,7 +218,7 @@ if (! function_exists('covers')) {
|
|||||||
|
|
||||||
/** @var MutationTestRunner $runner */
|
/** @var MutationTestRunner $runner */
|
||||||
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
||||||
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
|
/** @var ConfigurationRepository $configurationRepository */
|
||||||
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
||||||
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
||||||
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
||||||
@@ -256,14 +238,14 @@ if (! function_exists('mutates')) {
|
|||||||
*/
|
*/
|
||||||
function mutates(array|string ...$targets): void
|
function mutates(array|string ...$targets): void
|
||||||
{
|
{
|
||||||
$filename = Backtrace::file();
|
$filename = Backtrace::testFile();
|
||||||
|
|
||||||
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
||||||
$beforeEachCall->group('__pest_mutate_only');
|
$beforeEachCall->group('__pest_mutate_only');
|
||||||
|
|
||||||
/** @var MutationTestRunner $runner */
|
/** @var MutationTestRunner $runner */
|
||||||
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
||||||
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
|
/** @var ConfigurationRepository $configurationRepository */
|
||||||
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
||||||
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
||||||
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
||||||
@@ -320,7 +302,7 @@ if (! function_exists('visit')) {
|
|||||||
*/
|
*/
|
||||||
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
|
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
|
||||||
{
|
{
|
||||||
if (! class_exists(\Pest\Browser\Configuration::class)) {
|
if (! class_exists(Pest\Browser\Configuration::class)) {
|
||||||
PluginBrowser::install();
|
PluginBrowser::install();
|
||||||
|
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|||||||
+33
-9
@@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Kernel
|
final class Kernel
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Either the kernel is terminated or not.
|
||||||
|
*/
|
||||||
|
private bool $terminated = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Kernel bootstrappers.
|
* The Kernel bootstrappers.
|
||||||
*
|
*
|
||||||
@@ -36,6 +41,8 @@ final readonly class Kernel
|
|||||||
*/
|
*/
|
||||||
private const array BOOTSTRAPPERS = [
|
private const array BOOTSTRAPPERS = [
|
||||||
Bootstrappers\BootOverrides::class,
|
Bootstrappers\BootOverrides::class,
|
||||||
|
Bootstrappers\BootPhpUnitConfiguration::class,
|
||||||
|
Plugins\Tia\Bootstrapper::class,
|
||||||
Bootstrappers\BootSubscribers::class,
|
Bootstrappers\BootSubscribers::class,
|
||||||
Bootstrappers\BootFiles::class,
|
Bootstrappers\BootFiles::class,
|
||||||
Bootstrappers\BootView::class,
|
Bootstrappers\BootView::class,
|
||||||
@@ -43,15 +50,22 @@ final readonly class Kernel
|
|||||||
Bootstrappers\BootExcludeList::class,
|
Bootstrappers\BootExcludeList::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Kernel restarters — resolved and invoked from `bin/pest`
|
||||||
|
* before any other Pest class is touched, so the list is exposed
|
||||||
|
* on the Kernel rather than driven from `bin/pest` directly.
|
||||||
|
*
|
||||||
|
* @var array<int, class-string<Contracts\Restarter>>
|
||||||
|
*/
|
||||||
|
public const array RESTARTERS = [
|
||||||
|
Restarters\XdebugRestarter::class,
|
||||||
|
Restarters\PcovRestarter::class,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Kernel instance.
|
* Creates a new Kernel instance.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
|
||||||
private Application $application,
|
|
||||||
private OutputInterface $output,
|
|
||||||
) {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boots the Kernel.
|
* Boots the Kernel.
|
||||||
@@ -112,9 +126,13 @@ final readonly class Kernel
|
|||||||
$configuration = Registry::get();
|
$configuration = Registry::get();
|
||||||
$result = Facade::result();
|
$result = Facade::result();
|
||||||
|
|
||||||
return CallsAddsOutput::execute(
|
$result = CallsAddsOutput::execute(
|
||||||
Result::exitCode($configuration, $result),
|
Result::exitCode($configuration, $result),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->terminate();
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,6 +140,12 @@ final readonly class Kernel
|
|||||||
*/
|
*/
|
||||||
public function terminate(): void
|
public function terminate(): void
|
||||||
{
|
{
|
||||||
|
if ($this->terminated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->terminated = true;
|
||||||
|
|
||||||
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
|
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
|
||||||
|
|
||||||
assert($preBufferOutput instanceof KernelDump);
|
assert($preBufferOutput instanceof KernelDump);
|
||||||
@@ -139,7 +163,7 @@ final readonly class Kernel
|
|||||||
$this->terminate();
|
$this->terminate();
|
||||||
|
|
||||||
if (is_array($error = error_get_last())) {
|
if (is_array($error = error_get_last())) {
|
||||||
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
|
if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest;
|
namespace Pest;
|
||||||
|
|
||||||
|
use Laravel\Pao\Execution;
|
||||||
use Pest\Support\View;
|
use Pest\Support\View;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ final class KernelDump
|
|||||||
*/
|
*/
|
||||||
public function enable(): void
|
public function enable(): void
|
||||||
{
|
{
|
||||||
|
if (class_exists(Execution::class) && Execution::running()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ob_start(function (string $message): string {
|
ob_start(function (string $message): string {
|
||||||
$this->buffer .= $message;
|
$this->buffer .= $message;
|
||||||
|
|
||||||
@@ -68,6 +73,10 @@ final class KernelDump
|
|||||||
|
|
||||||
$type = 'INFO';
|
$type = 'INFO';
|
||||||
|
|
||||||
|
if (is_array($error = error_get_last()) && in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->isInternalError($this->buffer)) {
|
if ($this->isInternalError($this->buffer)) {
|
||||||
$type = 'ERROR';
|
$type = 'ERROR';
|
||||||
$this->buffer = str_replace(
|
$this->buffer = str_replace(
|
||||||
@@ -107,7 +116,6 @@ final class KernelDump
|
|||||||
*/
|
*/
|
||||||
private function isInternalError(string $output): bool
|
private function isInternalError(string $output): bool
|
||||||
{
|
{
|
||||||
return str_contains($output, 'An error occurred inside PHPUnit.')
|
return str_contains($output, 'An error occurred inside PHPUnit.');
|
||||||
|| str_contains($output, 'Fatal error');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-21
@@ -12,12 +12,9 @@ use PHPUnit\Event\Code\Test;
|
|||||||
use PHPUnit\Event\Code\TestMethod;
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
use PHPUnit\Event\Code\Throwable;
|
use PHPUnit\Event\Code\Throwable;
|
||||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||||
|
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||||
use PHPUnit\Event\Test\ConsideredRisky;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\Errored;
|
|
||||||
use PHPUnit\Event\Test\Failed;
|
|
||||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
|
||||||
use PHPUnit\Event\TestSuite\TestSuite;
|
use PHPUnit\Event\TestSuite\TestSuite;
|
||||||
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
||||||
use PHPUnit\Framework\Exception as FrameworkException;
|
use PHPUnit\Framework\Exception as FrameworkException;
|
||||||
@@ -151,7 +148,7 @@ final readonly class Converter
|
|||||||
{
|
{
|
||||||
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
|
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
|
||||||
$firstTest = $this->getFirstTest($testSuite);
|
$firstTest = $this->getFirstTest($testSuite);
|
||||||
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
|
if ($firstTest instanceof TestMethod) {
|
||||||
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
|
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +176,7 @@ final readonly class Converter
|
|||||||
public function getTestSuiteLocation(TestSuite $testSuite): ?string
|
public function getTestSuiteLocation(TestSuite $testSuite): ?string
|
||||||
{
|
{
|
||||||
$firstTest = $this->getFirstTest($testSuite);
|
$firstTest = $this->getFirstTest($testSuite);
|
||||||
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
|
if (! $firstTest instanceof TestMethod) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$path = $firstTest->testDox()->prettifiedClassName();
|
$path = $firstTest->testDox()->prettifiedClassName();
|
||||||
@@ -252,23 +249,29 @@ final readonly class Converter
|
|||||||
...$result->testMarkedIncompleteEvents(),
|
...$result->testMarkedIncompleteEvents(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$numberOfNotPassedTests = count(
|
$notPassedTests = [];
|
||||||
array_unique(
|
|
||||||
array_map(
|
foreach ($events as $event) {
|
||||||
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
if ($event instanceof AfterLastTestMethodErrored) {
|
||||||
if ($event instanceof BeforeFirstTestMethodErrored
|
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||||
|| $event instanceof AfterLastTestMethodErrored) {
|
continue;
|
||||||
return $event->testClassName();
|
}
|
||||||
|
if ($event instanceof AfterLastTestMethodFailed) {
|
||||||
|
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
|
||||||
|
$notPassedTests[] = $event->testClassName();
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getTestCaseLocation($event->test());
|
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||||
},
|
}
|
||||||
$events
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
$numberOfPassedTests = $result->numberOfTestsRun()
|
||||||
|
- count(array_unique($notPassedTests))
|
||||||
|
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||||
|
|
||||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
|||||||
{
|
{
|
||||||
public function notify(Skipped $event): void
|
public function notify(Skipped $event): void
|
||||||
{
|
{
|
||||||
|
if ($event->message() === '__TODO__') {
|
||||||
|
return; // "todo" tests are reported in the summary, not as ignored tests...
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger()->testSkipped($event);
|
$this->logger()->testSkipped($event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ final class TeamCityLogger
|
|||||||
|
|
||||||
public function testFinished(Finished $event): void
|
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.');
|
throw ShouldNotHappen::fromMessage('Start time has not been set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +241,12 @@ final class TeamCityLogger
|
|||||||
$telemetry->memoryUsageSinceStart(),
|
$telemetry->memoryUsageSinceStart(),
|
||||||
$telemetry->durationSincePrevious(),
|
$telemetry->durationSincePrevious(),
|
||||||
$telemetry->memoryUsageSincePrevious(),
|
$telemetry->memoryUsageSincePrevious(),
|
||||||
|
$telemetry->userCpuTimeSinceStart(),
|
||||||
|
$telemetry->systemCpuTimeSinceStart(),
|
||||||
|
$telemetry->totalCpuTimeSinceStart(),
|
||||||
|
$telemetry->userCpuTimeSincePrevious(),
|
||||||
|
$telemetry->systemCpuTimeSincePrevious(),
|
||||||
|
$telemetry->totalCpuTimeSincePrevious(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+160
-12
@@ -9,10 +9,12 @@ use Closure;
|
|||||||
use Countable;
|
use Countable;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Error;
|
use Error;
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
use Pest\Exceptions\InvalidExpectationValue;
|
use Pest\Exceptions\InvalidExpectationValue;
|
||||||
use Pest\Matchers\Any;
|
use Pest\Matchers\Any;
|
||||||
|
use Pest\Plugins\Snapshot;
|
||||||
use Pest\Support\Arr;
|
use Pest\Support\Arr;
|
||||||
use Pest\Support\Exporter;
|
use Pest\Support\Exporter;
|
||||||
use Pest\Support\NullClosure;
|
use Pest\Support\NullClosure;
|
||||||
@@ -842,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, '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(),
|
||||||
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),
|
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 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),
|
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||||
@@ -850,18 +852,31 @@ final class Expectation
|
|||||||
default => InvalidExpectationValue::expected('array|object|string'),
|
default => InvalidExpectationValue::expected('array|object|string'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if ($snapshots->has()) {
|
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 {
|
|
||||||
$filename = $snapshots->save($string);
|
$filename = $snapshots->save($string);
|
||||||
|
|
||||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
|
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;
|
return $this;
|
||||||
@@ -921,7 +936,7 @@ final class Expectation
|
|||||||
|
|
||||||
if ($exception instanceof Closure) {
|
if ($exception instanceof Closure) {
|
||||||
$callback = $exception;
|
$callback = $exception;
|
||||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||||
|
|
||||||
if (count($parameters) !== 1) {
|
if (count($parameters) !== 1) {
|
||||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||||
@@ -939,6 +954,7 @@ final class Expectation
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
|
||||||
if ($exception instanceof Throwable) {
|
if ($exception instanceof Throwable) {
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
expect($e)
|
expect($e)
|
||||||
->toBeInstanceOf($exception::class, $message)
|
->toBeInstanceOf($exception::class, $message)
|
||||||
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
||||||
@@ -983,7 +999,7 @@ final class Expectation
|
|||||||
*/
|
*/
|
||||||
private function export(mixed $value): string
|
private function export(mixed $value): string
|
||||||
{
|
{
|
||||||
if (! $this->exporter instanceof \Pest\Support\Exporter) {
|
if (! $this->exporter instanceof Exporter) {
|
||||||
$this->exporter = Exporter::default();
|
$this->exporter = Exporter::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,6 +1142,22 @@ final class Expectation
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a ULID.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeUlid(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue(Str::isUlid($this->value), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that the value is between 2 specified values
|
* Asserts that the value is between 2 specified values
|
||||||
*
|
*
|
||||||
@@ -1139,6 +1171,22 @@ final class Expectation
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is an email address.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeEmail(string $message = ''): self
|
||||||
|
{
|
||||||
|
if ($message === '') {
|
||||||
|
$message = "Failed asserting that {$this->value} is an email address.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that the value is a url
|
* Asserts that the value is a url
|
||||||
*
|
*
|
||||||
@@ -1171,4 +1219,104 @@ final class Expectation
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is an IP address.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeIpAddress(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a MAC address.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeMacAddress(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a hostname.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeHostname(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a domain name.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeDomain(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
|
||||||
|
&& str_contains($this->value, '.');
|
||||||
|
|
||||||
|
Assert::assertTrue($isValid, $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a base64-encoded string.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeBase64(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($this->value, true);
|
||||||
|
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a hexadecimal string.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeHexadecimal(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue(ctype_xdigit($this->value), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 pest-plugin-phpstan 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\PendingCalls\Concerns;
|
namespace Pest\PendingCalls\Concerns;
|
||||||
|
|
||||||
|
use Pest\Support\Description;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -12,14 +14,14 @@ trait Describable
|
|||||||
/**
|
/**
|
||||||
* Note: this is property is not used; however, it gets added automatically by rector php.
|
* Note: this is property is not used; however, it gets added automatically by rector php.
|
||||||
*
|
*
|
||||||
* @var array<int, \Pest\Support\Description>
|
* @var array<int, Description>
|
||||||
*/
|
*/
|
||||||
public array $__describing;
|
public array $__describing;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The describing of the test case.
|
* The describing of the test case.
|
||||||
*
|
*
|
||||||
* @var array<int, \Pest\Support\Description>
|
* @var array<int, Description>
|
||||||
*/
|
*/
|
||||||
public array $describing = [];
|
public array $describing = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Pest\PendingCalls;
|
namespace Pest\PendingCalls;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Pest\Support\Backtrace;
|
|
||||||
use Pest\Support\Description;
|
use Pest\Support\Description;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
@@ -53,7 +52,11 @@ final class DescribeCall
|
|||||||
*/
|
*/
|
||||||
public function __destruct()
|
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;
|
self::$describing[] = $this->description;
|
||||||
|
|
||||||
@@ -71,12 +74,13 @@ final class DescribeCall
|
|||||||
*/
|
*/
|
||||||
public function __call(string $name, array $arguments): self
|
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->describing = array_merge(
|
||||||
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
|
DescribeCall::describing(),
|
||||||
|
[$this->description]
|
||||||
$this->currentBeforeEachCall->describing[] = $this->description;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->currentBeforeEachCall->{$name}(...$arguments);
|
$this->currentBeforeEachCall->{$name}(...$arguments);
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ use Pest\Support\NullClosure;
|
|||||||
use Pest\Support\Str;
|
use Pest\Support\Str;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\AssertionFailedError;
|
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;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,7 +215,7 @@ final class TestCall // @phpstan-ignore-line
|
|||||||
{
|
{
|
||||||
foreach ($groups as $group) {
|
foreach ($groups as $group) {
|
||||||
$this->testCaseMethod->attributes[] = new Attribute(
|
$this->testCaseMethod->attributes[] = new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\Group::class,
|
Group::class,
|
||||||
[$group],
|
[$group],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -408,6 +412,20 @@ final class TestCall // @phpstan-ignore-line
|
|||||||
return $this;
|
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".
|
* Marks the test as "todo".
|
||||||
*/
|
*/
|
||||||
@@ -604,7 +622,7 @@ final class TestCall // @phpstan-ignore-line
|
|||||||
{
|
{
|
||||||
foreach ($classes as $class) {
|
foreach ($classes as $class) {
|
||||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\CoversClass::class,
|
CoversClass::class,
|
||||||
[$class],
|
[$class],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -627,7 +645,7 @@ final class TestCall // @phpstan-ignore-line
|
|||||||
{
|
{
|
||||||
foreach ($traits as $trait) {
|
foreach ($traits as $trait) {
|
||||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\CoversTrait::class,
|
CoversTrait::class,
|
||||||
[$trait],
|
[$trait],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -650,7 +668,7 @@ final class TestCall // @phpstan-ignore-line
|
|||||||
{
|
{
|
||||||
foreach ($functions as $function) {
|
foreach ($functions as $function) {
|
||||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||||
\PHPUnit\Framework\Attributes\CoversFunction::class,
|
CoversFunction::class,
|
||||||
[$function],
|
[$function],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ final class UsesCall
|
|||||||
$this->targets = [$filename];
|
$this->targets = [$filename];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
|
||||||
*/
|
|
||||||
public function compact(): self
|
public function compact(): self
|
||||||
{
|
{
|
||||||
DefaultPrinter::compact(true);
|
DefaultPrinter::compact(true);
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
|||||||
|
|
||||||
function version(): string
|
function version(): string
|
||||||
{
|
{
|
||||||
return '4.3.2';
|
return '5.0.4';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testDirectory(string $file = ''): string
|
function testDirectory(string $file = ''): string
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||||
return $this->pushArgument('--cache-result', $arguments);
|
return $this->pushArgument('--cache-result', $arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,40 @@ trait HandleArguments
|
|||||||
*/
|
*/
|
||||||
public function popArgument(string $argument, array $arguments): array
|
public function popArgument(string $argument, array $arguments): array
|
||||||
{
|
{
|
||||||
$arguments = array_flip($arguments);
|
$key = array_search($argument, $arguments, true);
|
||||||
|
|
||||||
unset($arguments[$argument]);
|
while ($key !== false) {
|
||||||
|
unset($arguments[$key]);
|
||||||
|
$key = array_search($argument, $arguments, true);
|
||||||
|
}
|
||||||
|
|
||||||
return array_values(array_flip($arguments));
|
return array_values($arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
*/
|
*/
|
||||||
final class Coverage implements AddsOutput, HandlesArguments
|
final class Coverage implements AddsOutput, HandlesArguments
|
||||||
{
|
{
|
||||||
|
use Concerns\HandleArguments;
|
||||||
|
|
||||||
private const string COVERAGE_OPTION = 'coverage';
|
private const string COVERAGE_OPTION = 'coverage';
|
||||||
|
|
||||||
private const string MIN_OPTION = 'min';
|
private const string MIN_OPTION = 'min';
|
||||||
|
|
||||||
private const string EXACTLY_OPTION = 'exactly';
|
private const string EXACTLY_OPTION = 'exactly';
|
||||||
|
|
||||||
|
private const string ONLY_COVERED_OPTION = 'only-covered';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether it should show the coverage or not.
|
* Whether it should show the coverage or not.
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +47,11 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
*/
|
*/
|
||||||
public ?float $coverageExactly = null;
|
public ?float $coverageExactly = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether it should show only covered files.
|
||||||
|
*/
|
||||||
|
public bool $showOnlyCovered = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Plugin instance.
|
* Creates a new Plugin instance.
|
||||||
*/
|
*/
|
||||||
@@ -57,7 +66,7 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
public function handleArguments(array $originals): array
|
public function handleArguments(array $originals): array
|
||||||
{
|
{
|
||||||
$arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool {
|
$arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool {
|
||||||
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION] as $option) {
|
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) {
|
||||||
if ($original === sprintf('--%s', $option)) {
|
if ($original === sprintf('--%s', $option)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -70,16 +79,15 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
return false;
|
return false;
|
||||||
}))];
|
}))];
|
||||||
|
|
||||||
$originals = array_flip($originals);
|
|
||||||
foreach ($arguments as $argument) {
|
foreach ($arguments as $argument) {
|
||||||
unset($originals[$argument]);
|
$originals = $this->popArgument($argument, $originals);
|
||||||
}
|
}
|
||||||
$originals = array_flip($originals);
|
|
||||||
|
|
||||||
$inputs = [];
|
$inputs = [];
|
||||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||||
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
|
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
|
||||||
$inputs[] = new InputOption(self::EXACTLY_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));
|
$input = new ArgvInput($arguments, new InputDefinition($inputs));
|
||||||
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
|
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
|
||||||
@@ -120,6 +128,10 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
$this->coverageExactly = (float) $exactlyOption;
|
$this->coverageExactly = (float) $exactlyOption;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) {
|
||||||
|
$this->showOnlyCovered = true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
|
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
|
||||||
$this->compact = true;
|
$this->compact = true;
|
||||||
}
|
}
|
||||||
@@ -144,7 +156,7 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact);
|
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered);
|
||||||
$exitCode = (int) ($coverage < $this->coverageMin);
|
$exitCode = (int) ($coverage < $this->coverageMin);
|
||||||
|
|
||||||
if ($exitCode === 0 && $this->coverageExactly !== null) {
|
if ($exitCode === 0 && $this->coverageExactly !== null) {
|
||||||
|
|||||||
@@ -107,6 +107,48 @@ final readonly class Help implements HandlesArguments
|
|||||||
'desc' => 'Initialise a standard Pest configuration',
|
'desc' => 'Initialise a standard Pest configuration',
|
||||||
]], ...$content['Configuration']];
|
]], ...$content['Configuration']];
|
||||||
|
|
||||||
|
$content['AI'] = [
|
||||||
|
[
|
||||||
|
'arg' => '--ai',
|
||||||
|
'desc' => 'Run a code snippet as a fully scaffolded test for AI verification',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$content['Tia'] = [
|
||||||
|
[
|
||||||
|
'arg' => '--tia',
|
||||||
|
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--no-tia',
|
||||||
|
'desc' => 'Disable test impact analysis for this run',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--tia --fresh',
|
||||||
|
'desc' => 'Discard the recorded dependency graph and record it again',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--tia --filtered',
|
||||||
|
'desc' => 'Narrow the run to the affected test files only',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--tia --locally',
|
||||||
|
'desc' => 'Enable test impact analysis on local machines only',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--tia --baselined',
|
||||||
|
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--tia --refetch',
|
||||||
|
'desc' => 'Force a fresh fetch of the shared dependency graph',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'arg' => '--baseline',
|
||||||
|
'desc' => 'Output to standard output the test impact analysis storage directory',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
$content['Execution'] = [...[
|
$content['Execution'] = [...[
|
||||||
[
|
[
|
||||||
'arg' => '--parallel',
|
'arg' => '--parallel',
|
||||||
@@ -116,6 +158,10 @@ final readonly class Help implements HandlesArguments
|
|||||||
'arg' => '--update-snapshots',
|
'arg' => '--update-snapshots',
|
||||||
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
|
'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['Execution']];
|
||||||
|
|
||||||
$content['Selection'] = [[
|
$content['Selection'] = [[
|
||||||
@@ -145,6 +191,9 @@ final readonly class Help implements HandlesArguments
|
|||||||
], [
|
], [
|
||||||
'arg' => '--dirty',
|
'arg' => '--dirty',
|
||||||
'desc' => 'Only run tests that have uncommitted changes according to Git',
|
'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['Selection']];
|
||||||
|
|
||||||
$content['Reporting'] = [...$content['Reporting'], ...[
|
$content['Reporting'] = [...$content['Reporting'], ...[
|
||||||
@@ -160,6 +209,12 @@ final readonly class Help implements HandlesArguments
|
|||||||
], [
|
], [
|
||||||
'arg' => '--coverage --min',
|
'arg' => '--coverage --min',
|
||||||
'desc' => 'Set the minimum required coverage percentage, and fail if not met',
|
'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['Code Coverage']];
|
||||||
|
|
||||||
$content['Mutation Testing'] = [[
|
$content['Mutation Testing'] = [[
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments
|
|||||||
/**
|
/**
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
private const array 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.
|
* 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
|
$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);
|
return CallsAddsOutput::execute($exitCode);
|
||||||
}
|
}
|
||||||
@@ -176,13 +178,7 @@ final class Parallel implements HandlesArguments
|
|||||||
{
|
{
|
||||||
$arguments = new ArgvInput;
|
$arguments = new ArgvInput;
|
||||||
|
|
||||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,4 +193,18 @@ final class Parallel implements HandlesArguments
|
|||||||
|
|
||||||
return $this->popArgument('-p', $arguments);
|
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 Closure;
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Illuminate\Testing\ParallelRunner;
|
use Illuminate\Testing\ParallelRunner;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
use ParaTest\RunnerInterface;
|
use ParaTest\RunnerInterface;
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
@@ -39,13 +40,13 @@ final class Laravel implements HandlesArguments
|
|||||||
* Executes the given closure when running Laravel.
|
* Executes the given closure when running Laravel.
|
||||||
*
|
*
|
||||||
* @param array<int, string> $arguments
|
* @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>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
private function whenUsingLaravel(array $arguments, Closure $closure): array
|
private function whenUsingLaravel(array $arguments, Closure $closure): array
|
||||||
{
|
{
|
||||||
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
|
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
|
||||||
$isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class);
|
$isLaravelPackage = class_exists(TestCase::class);
|
||||||
|
|
||||||
if ($isLaravelApplication && ! $isLaravelPackage) {
|
if ($isLaravelApplication && ! $isLaravelPackage) {
|
||||||
return $closure($arguments);
|
return $closure($arguments);
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ final class ResultPrinter
|
|||||||
public function flush(): void {}
|
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()) {
|
if (! $this->options->configuration->hasLogfileTeamcity()) {
|
||||||
return;
|
return;
|
||||||
@@ -92,14 +94,13 @@ final class ResultPrinter
|
|||||||
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
|
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<SplFileInfo> $teamcityFiles */
|
|
||||||
public function printFeedback(
|
public function printFeedback(
|
||||||
SplFileInfo $progressFile,
|
SplFileInfo $progressFile,
|
||||||
SplFileInfo $outputFile,
|
SplFileInfo $outputFile,
|
||||||
array $teamcityFiles
|
?SplFileInfo $teamcityFile,
|
||||||
): void {
|
): void {
|
||||||
if ($this->options->needsTeamcity) {
|
if ($this->options->needsTeamcity && $teamcityFile instanceof SplFileInfo) {
|
||||||
$teamcityProgress = $this->tailMultiple($teamcityFiles);
|
$teamcityProgress = $this->tailMultiple([$teamcityFile]);
|
||||||
|
|
||||||
if ($this->teamcityLogFileHandle !== null) {
|
if ($this->teamcityLogFileHandle !== null) {
|
||||||
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
|
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
|
||||||
@@ -119,10 +120,6 @@ final class ResultPrinter
|
|||||||
|
|
||||||
$unexpectedOutput = $this->tail($outputFile);
|
$unexpectedOutput = $this->tail($outputFile);
|
||||||
if ($unexpectedOutput !== '') {
|
if ($unexpectedOutput !== '') {
|
||||||
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->output->write($unexpectedOutput);
|
$this->output->write($unexpectedOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +168,19 @@ final class ResultPrinter
|
|||||||
|
|
||||||
$state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult);
|
$state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult);
|
||||||
|
|
||||||
|
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->errors($state);
|
||||||
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
|
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function printFeedbackItem(string $item): void
|
private function printFeedbackItem(string $item): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ namespace Pest\Plugins\Parallel\Paratest;
|
|||||||
use const DIRECTORY_SEPARATOR;
|
use const DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||||
use ParaTest\Coverage\CoverageMerger;
|
|
||||||
use ParaTest\JUnit\LogMerger;
|
use ParaTest\JUnit\LogMerger;
|
||||||
use ParaTest\JUnit\Writer;
|
use ParaTest\JUnit\Writer;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
use ParaTest\RunnerInterface;
|
use ParaTest\RunnerInterface;
|
||||||
|
use ParaTest\WrapperRunner\MissingResultsException;
|
||||||
use ParaTest\WrapperRunner\SuiteLoader;
|
use ParaTest\WrapperRunner\SuiteLoader;
|
||||||
use ParaTest\WrapperRunner\WrapperWorker;
|
use ParaTest\WrapperRunner\WrapperWorker;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Result;
|
use Pest\Result;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Event\Facade as EventFacade;
|
use PHPUnit\Event\Facade as EventFacade;
|
||||||
@@ -25,11 +26,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
|||||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||||
use PHPUnit\Util\ExcludeList;
|
use PHPUnit\Util\ExcludeList;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use SebastianBergmann\CodeCoverage\Node\Builder;
|
||||||
|
use SebastianBergmann\CodeCoverage\Serialization\Merger;
|
||||||
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
|
||||||
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
|
||||||
use SebastianBergmann\Timer\Timer;
|
use SebastianBergmann\Timer\Timer;
|
||||||
use SplFileInfo;
|
use SplFileInfo;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
use function array_merge;
|
use function array_merge;
|
||||||
use function array_merge_recursive;
|
use function array_merge_recursive;
|
||||||
use function array_shift;
|
use function array_shift;
|
||||||
@@ -37,8 +44,11 @@ use function assert;
|
|||||||
use function count;
|
use function count;
|
||||||
use function dirname;
|
use function dirname;
|
||||||
use function file_get_contents;
|
use function file_get_contents;
|
||||||
|
use function filesize;
|
||||||
|
use function is_file;
|
||||||
use function max;
|
use function max;
|
||||||
use function realpath;
|
use function realpath;
|
||||||
|
use function str_starts_with;
|
||||||
use function unlink;
|
use function unlink;
|
||||||
use function unserialize;
|
use function unserialize;
|
||||||
use function usleep;
|
use function usleep;
|
||||||
@@ -48,6 +58,11 @@ use function usleep;
|
|||||||
*/
|
*/
|
||||||
final class WrapperRunner implements RunnerInterface
|
final class WrapperRunner implements RunnerInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* The merged test result from the parallel run.
|
||||||
|
*/
|
||||||
|
public static ?TestResult $result = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The time to sleep between cycles.
|
* The time to sleep between cycles.
|
||||||
*/
|
*/
|
||||||
@@ -77,6 +92,18 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
/** @var array<int,int> */
|
/** @var array<int,int> */
|
||||||
private array $batches = [];
|
private array $batches = [];
|
||||||
|
|
||||||
|
/** @var array<non-empty-string,true> */
|
||||||
|
private array $requiredTestResultFiles = [];
|
||||||
|
|
||||||
|
/** @var array<non-empty-string,true> */
|
||||||
|
private array $requiredCoverageFiles = [];
|
||||||
|
|
||||||
|
/** @var list<SplFileInfo> */
|
||||||
|
private array $statusFiles = [];
|
||||||
|
|
||||||
|
/** @var list<SplFileInfo> */
|
||||||
|
private array $progressFiles = [];
|
||||||
|
|
||||||
/** @var list<SplFileInfo> */
|
/** @var list<SplFileInfo> */
|
||||||
private array $unexpectedOutputFiles = [];
|
private array $unexpectedOutputFiles = [];
|
||||||
|
|
||||||
@@ -129,8 +156,10 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
/** @var array<int, non-empty-string> $parameters */
|
/** @var array<int, non-empty-string> $parameters */
|
||||||
$parameters = $this->handleLaravelHerd($parameters);
|
$parameters = $this->handleLaravelHerd($parameters);
|
||||||
|
$parameters = $this->handleTia($parameters);
|
||||||
|
|
||||||
$parameters[] = $wrapper;
|
$parameters[] = $wrapper;
|
||||||
|
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||||
|
|
||||||
$this->parameters = $parameters;
|
$this->parameters = $parameters;
|
||||||
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry;
|
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry;
|
||||||
@@ -139,7 +168,6 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
public function run(): int
|
public function run(): int
|
||||||
{
|
{
|
||||||
$directory = dirname(__DIR__);
|
$directory = dirname(__DIR__);
|
||||||
assert($directory !== '');
|
|
||||||
ExcludeList::addDirectory($directory);
|
ExcludeList::addDirectory($directory);
|
||||||
TestResultFacade::init();
|
TestResultFacade::init();
|
||||||
EventFacade::instance()->seal();
|
EventFacade::instance()->seal();
|
||||||
@@ -176,6 +204,19 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
return $parameters;
|
return $parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, non-empty-string> $parameters
|
||||||
|
* @return array<int, non-empty-string>
|
||||||
|
*/
|
||||||
|
private function handleTia(array $parameters): array
|
||||||
|
{
|
||||||
|
if (! Tia::recordsEdgesInWorkers()) {
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
|
||||||
|
}
|
||||||
|
|
||||||
private function startWorkers(): void
|
private function startWorkers(): void
|
||||||
{
|
{
|
||||||
for ($token = 1; $token <= $this->options->processes; $token++) {
|
for ($token = 1; $token <= $this->options->processes; $token++) {
|
||||||
@@ -206,7 +247,7 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$this->exitcode > 0
|
$this->exitcode > 0
|
||||||
&& $this->options->configuration->stopOnFailure()
|
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
||||||
) {
|
) {
|
||||||
$this->pending = [];
|
$this->pending = [];
|
||||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||||
@@ -221,11 +262,23 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
private function flushWorker(WrapperWorker $worker): void
|
private function flushWorker(WrapperWorker $worker): void
|
||||||
{
|
{
|
||||||
|
if ($worker->hasExecutedTests()) {
|
||||||
|
$testResultFile = $worker->testResultFile->getPathname();
|
||||||
|
|
||||||
|
if ($testResultFile !== '') {
|
||||||
|
$this->requiredTestResultFiles[$testResultFile] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
|
||||||
|
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->exitcode = max($this->exitcode, $worker->getExitCode());
|
$this->exitcode = max($this->exitcode, $worker->getExitCode());
|
||||||
$this->printer->printFeedback(
|
$this->printer->printFeedback(
|
||||||
$worker->progressFile,
|
$worker->progressFile,
|
||||||
$worker->unexpectedOutputFile,
|
$worker->unexpectedOutputFile,
|
||||||
$this->teamcityFiles,
|
$worker->teamcityFile ?? null,
|
||||||
);
|
);
|
||||||
$worker->reset();
|
$worker->reset();
|
||||||
}
|
}
|
||||||
@@ -268,10 +321,15 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
$worker->start();
|
$worker->start();
|
||||||
$this->batches[$token] = 0;
|
$this->batches[$token] = 0;
|
||||||
|
|
||||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
$this->statusFiles[] = $worker->statusFile;
|
||||||
|
$this->progressFiles[] = $worker->progressFile;
|
||||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||||
$this->testResultFiles[] = $worker->testResultFile;
|
$this->testResultFiles[] = $worker->testResultFile;
|
||||||
|
|
||||||
|
if (isset($worker->resultCacheFile)) {
|
||||||
|
$this->resultCacheFiles[] = $worker->resultCacheFile;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($worker->junitFile)) {
|
if (isset($worker->junitFile)) {
|
||||||
$this->junitFiles[] = $worker->junitFile;
|
$this->junitFiles[] = $worker->junitFile;
|
||||||
}
|
}
|
||||||
@@ -304,6 +362,20 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
private function complete(TestResult $testResultSum): int
|
private function complete(TestResult $testResultSum): int
|
||||||
{
|
{
|
||||||
|
$missingTestResultFiles = [];
|
||||||
|
|
||||||
|
foreach ($this->requiredTestResultFiles as $filePath => $true) {
|
||||||
|
if (is_file($filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingTestResultFiles[] = $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingTestResultFiles !== []) {
|
||||||
|
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($this->testResultFiles as $testResultFile) {
|
foreach ($this->testResultFiles as $testResultFile) {
|
||||||
if (! $testResultFile->isFile()) {
|
if (! $testResultFile->isFile()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -338,6 +410,20 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueDeprecationEvents(), $testResult->testRunnerTriggeredIssueDeprecationEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueErrorEvents(), $testResult->testRunnerTriggeredIssueErrorEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueNoticeEvents(), $testResult->testRunnerTriggeredIssueNoticeEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(), $testResult->testRunnerTriggeredIssuePhpDeprecationEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(), $testResult->testRunnerTriggeredIssuePhpNoticeEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpWarningEvents(), $testResult->testRunnerTriggeredIssuePhpWarningEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueWarningEvents(), $testResult->testRunnerTriggeredIssueWarningEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||||
@@ -373,8 +459,15 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||||
array_values(array_filter(
|
array_values(array_filter(
|
||||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
|
||||||
)),
|
)),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
|
||||||
$testResultSum->errors(),
|
$testResultSum->errors(),
|
||||||
$testResultSum->deprecations(),
|
$testResultSum->deprecations(),
|
||||||
$testResultSum->notices(),
|
$testResultSum->notices(),
|
||||||
@@ -385,6 +478,8 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
$testResultSum->numberOfIssuesIgnoredByBaseline(),
|
$testResultSum->numberOfIssuesIgnoredByBaseline(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
self::$result = $testResultSum;
|
||||||
|
|
||||||
if ($this->options->configuration->cacheResult()) {
|
if ($this->options->configuration->cacheResult()) {
|
||||||
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
|
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
|
||||||
foreach ($this->resultCacheFiles as $resultCacheFile) {
|
foreach ($this->resultCacheFiles as $resultCacheFile) {
|
||||||
@@ -408,8 +503,11 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||||
|
|
||||||
|
$this->clearFiles($this->statusFiles);
|
||||||
|
$this->clearFiles($this->progressFiles);
|
||||||
$this->clearFiles($this->unexpectedOutputFiles);
|
$this->clearFiles($this->unexpectedOutputFiles);
|
||||||
$this->clearFiles($this->testResultFiles);
|
$this->clearFiles($this->testResultFiles);
|
||||||
|
$this->clearFiles($this->resultCacheFiles);
|
||||||
$this->clearFiles($this->coverageFiles);
|
$this->clearFiles($this->coverageFiles);
|
||||||
$this->clearFiles($this->junitFiles);
|
$this->clearFiles($this->junitFiles);
|
||||||
$this->clearFiles($this->teamcityFiles);
|
$this->clearFiles($this->teamcityFiles);
|
||||||
@@ -424,6 +522,20 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$missingCoverageFiles = [];
|
||||||
|
|
||||||
|
foreach ($this->requiredCoverageFiles as $filePath => $true) {
|
||||||
|
if (is_file($filePath) && filesize($filePath) !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingCoverageFiles[] = $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingCoverageFiles !== []) {
|
||||||
|
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
|
||||||
|
}
|
||||||
|
|
||||||
$coverageManager = new CodeCoverage;
|
$coverageManager = new CodeCoverage;
|
||||||
$coverageManager->init(
|
$coverageManager->init(
|
||||||
$this->options->configuration,
|
$this->options->configuration,
|
||||||
@@ -439,10 +551,33 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
$coverageFiles = [];
|
||||||
foreach ($this->coverageFiles as $coverageFile) {
|
foreach ($this->coverageFiles as $fileInfo) {
|
||||||
$coverageMerger->addCoverageFromFile($coverageFile);
|
$realPath = $fileInfo->getRealPath();
|
||||||
|
if ($realPath !== false && $realPath !== '') {
|
||||||
|
$coverageFiles[] = $realPath;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
$serializedCoverage = (new Merger)->merge($coverageFiles);
|
||||||
|
|
||||||
|
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
|
||||||
|
$serializedCoverage['codeCoverage'],
|
||||||
|
$serializedCoverage['testResults'],
|
||||||
|
$serializedCoverage['basePath'],
|
||||||
|
);
|
||||||
|
$codeCoverage = $coverageManager->codeCoverage();
|
||||||
|
$codeCoverage->excludeUncoveredFiles();
|
||||||
|
|
||||||
|
$mergedData = $serializedCoverage['codeCoverage'];
|
||||||
|
$basePath = $serializedCoverage['basePath'];
|
||||||
|
if ($basePath !== '') {
|
||||||
|
foreach ($mergedData->coveredFiles() as $relativePath) {
|
||||||
|
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$codeCoverage->setData($mergedData);
|
||||||
|
$codeCoverage->setTests($serializedCoverage['testResults']);
|
||||||
|
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
|
||||||
|
|
||||||
$coverageManager->generateReports(
|
$coverageManager->generateReports(
|
||||||
$this->printer->printer,
|
$this->printer->printer,
|
||||||
@@ -483,15 +618,61 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
*/
|
*/
|
||||||
private function getTestFiles(SuiteLoader $suiteLoader): array
|
private function getTestFiles(SuiteLoader $suiteLoader): array
|
||||||
{
|
{
|
||||||
/** @var array<string, non-empty-string> $files */
|
/** @var array<string, null> $files */
|
||||||
$files = [
|
$files = [];
|
||||||
...array_values(array_filter(
|
|
||||||
|
foreach (array_filter(
|
||||||
$suiteLoader->tests,
|
$suiteLoader->tests,
|
||||||
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
|
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
|
||||||
)),
|
) as $filename) {
|
||||||
...TestSuite::getInstance()->tests->getFilenames(),
|
$resolved = realpath($filename) ?: $filename;
|
||||||
];
|
$files[$resolved] = null;
|
||||||
|
}
|
||||||
|
|
||||||
return $files; // @phpstan-ignore-line
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
|
|||||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
|
use PHPUnit\Event\Telemetry\CpuTime;
|
||||||
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
||||||
use PHPUnit\Event\Telemetry\HRTime;
|
use PHPUnit\Event\Telemetry\HRTime;
|
||||||
use PHPUnit\Event\Telemetry\Info;
|
use PHPUnit\Event\Telemetry\Info;
|
||||||
@@ -62,12 +63,12 @@ final class CompactPrinter
|
|||||||
/**
|
/**
|
||||||
* Creates a new instance of the Compact Printer.
|
* Creates a new instance of the Compact Printer.
|
||||||
*/
|
*/
|
||||||
public static function default(): self
|
public static function default(bool $decorated = true): self
|
||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
terminal(),
|
terminal(),
|
||||||
new ConsoleOutput(decorated: true),
|
new ConsoleOutput(decorated: $decorated),
|
||||||
new Style(new ConsoleOutput(decorated: true)),
|
new Style(new ConsoleOutput(decorated: $decorated)),
|
||||||
terminal()->width() - 4,
|
terminal()->width() - 4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -147,11 +148,20 @@ final class CompactPrinter
|
|||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
$garbageCollectorStatus,
|
$garbageCollectorStatus,
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
),
|
),
|
||||||
$telemetryDuration,
|
$telemetryDuration,
|
||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
|
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
|
||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->style->writeRecap($state, $telemetry, $testResult);
|
$this->style->writeRecap($state, $telemetry, $testResult);
|
||||||
|
|||||||
+460
-14
@@ -6,7 +6,13 @@ namespace Pest\Plugins;
|
|||||||
|
|
||||||
use Pest\Contracts\Plugins\AddsOutput;
|
use Pest\Contracts\Plugins\AddsOutput;
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
|
use Pest\Contracts\Plugins\Terminable;
|
||||||
use Pest\Exceptions\InvalidOption;
|
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\ArgvInput;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
@@ -15,12 +21,19 @@ use Symfony\Component\Process\Process;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Shard implements AddsOutput, HandlesArguments
|
final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||||
{
|
{
|
||||||
use Concerns\HandleArguments;
|
use Concerns\HandleArguments;
|
||||||
|
|
||||||
private const string SHARD_OPTION = 'shard';
|
private const string SHARD_OPTION = 'shard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum length allowed for the filter argument.
|
||||||
|
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
|
||||||
|
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
|
||||||
|
*/
|
||||||
|
private const int MAX_FILTER_LENGTH = 32768;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shard index and total number of shards.
|
* The shard index and total number of shards.
|
||||||
*
|
*
|
||||||
@@ -33,6 +46,40 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
*/
|
*/
|
||||||
private static ?array $shard = 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.
|
* Creates a new Plugin instance.
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +94,19 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
*/
|
*/
|
||||||
public function handleArguments(array $arguments): array
|
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)) {
|
if (! $this->hasArgument('--shard', $arguments)) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@@ -63,7 +123,25 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
|
|
||||||
/** @phpstan-ignore-next-line */
|
/** @phpstan-ignore-next-line */
|
||||||
$tests = $this->allTests($arguments);
|
$tests = $this->allTests($arguments);
|
||||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
|
||||||
|
$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 {
|
||||||
|
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||||
|
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
self::$shard = [
|
self::$shard = [
|
||||||
'index' => $index,
|
'index' => $index,
|
||||||
@@ -72,7 +150,45 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
'testsCount' => count($tests),
|
'testsCount' => count($tests),
|
||||||
];
|
];
|
||||||
|
|
||||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
if ($testsToRun === []) {
|
||||||
|
return $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filter = $this->buildFilterArgument($testsToRun);
|
||||||
|
|
||||||
|
$this->ensureFilterLengthIsSafe($filter);
|
||||||
|
|
||||||
|
return [...$arguments, '--filter', $filter];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
*/
|
*/
|
||||||
private function allTests(array $arguments): array
|
private function allTests(array $arguments): array
|
||||||
{
|
{
|
||||||
$output = (new Process([
|
$command = $this->buildListTestsCommand(
|
||||||
'php',
|
$arguments,
|
||||||
...$this->removeParallelArguments($arguments),
|
TestSuite::getInstance()->testPath,
|
||||||
'--list-tests',
|
);
|
||||||
]))->mustRun()->getOutput();
|
|
||||||
|
|
||||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
||||||
|
|
||||||
return array_values(array_unique($matches[1]));
|
return $this->parseListTestsOutput($output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
*/
|
*/
|
||||||
private function removeParallelArguments(array $arguments): array
|
private function removeParallelArguments(array $arguments): array
|
||||||
{
|
{
|
||||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
return array_values(array_filter(
|
||||||
|
$arguments,
|
||||||
|
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
|
||||||
|
&& ! str_starts_with($argument, '--processes'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the subprocess command used to enumerate tests via `--list-tests`.
|
||||||
|
*
|
||||||
|
* @param list<string> $arguments
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function buildListTestsCommand(array $arguments, string $testPath): array
|
||||||
|
{
|
||||||
|
$filtered = $this->removeParallelArguments($arguments);
|
||||||
|
|
||||||
|
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `--list-tests` output into a unique list of test class FQCNs.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function parseListTestsOutput(string $output): array
|
||||||
|
{
|
||||||
|
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
|
||||||
|
|
||||||
|
return array_values(array_unique($matches[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the filter argument for the given tests to run.
|
* Builds the filter argument for the given tests to run.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $testsToRun
|
||||||
*/
|
*/
|
||||||
private function buildFilterArgument(mixed $testsToRun): string
|
private function buildFilterArgument(array $testsToRun): string
|
||||||
{
|
{
|
||||||
return addslashes(implode('|', $testsToRun));
|
if ($testsToRun === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $tree */
|
||||||
|
$tree = [];
|
||||||
|
foreach ($testsToRun as $class) {
|
||||||
|
$parts = explode('\\', $class);
|
||||||
|
$current = &$tree;
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (! isset($current[$part])) {
|
||||||
|
$current[$part] = [];
|
||||||
|
}
|
||||||
|
$current = &$current[$part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildRegex = function (array $tree) use (&$buildRegex): string {
|
||||||
|
$parts = [];
|
||||||
|
foreach ($tree as $key => $sub) {
|
||||||
|
$subRegex = $buildRegex($sub);
|
||||||
|
if ($subRegex === '') {
|
||||||
|
$parts[] = preg_quote($key, '/');
|
||||||
|
} else {
|
||||||
|
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('|', $parts);
|
||||||
|
};
|
||||||
|
|
||||||
|
return $buildRegex($tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the filter length is safe for the current environment.
|
||||||
|
*
|
||||||
|
* @throws InvalidOption
|
||||||
|
*/
|
||||||
|
private function ensureFilterLengthIsSafe(string $filter): void
|
||||||
|
{
|
||||||
|
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
|
||||||
|
|
||||||
|
if (strlen($filter) > $maxLength) {
|
||||||
|
throw new InvalidOption(sprintf(
|
||||||
|
'The generated filter for this shard is too long (%d characters). '.
|
||||||
|
'This can cause issues with some environments (limit is %d characters). '.
|
||||||
|
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
|
||||||
|
strlen($filter),
|
||||||
|
$maxLength
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +313,22 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
*/
|
*/
|
||||||
public function addOutput(int $exitCode): int
|
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) {
|
if (self::$shard === null) {
|
||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
@@ -128,17 +341,250 @@ final class Shard implements AddsOutput, HandlesArguments
|
|||||||
] = self::$shard;
|
] = self::$shard;
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
|
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d%s.',
|
||||||
$index,
|
$index,
|
||||||
$total,
|
$total,
|
||||||
$testsRan,
|
$testsRan,
|
||||||
$testsRan === 1 ? '' : 's',
|
$testsRan === 1 ? '' : 's',
|
||||||
$testsCount,
|
$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;
|
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.
|
* Returns the shard information.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins;
|
namespace Pest\Plugins;
|
||||||
|
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
use Pest\Exceptions\InvalidOption;
|
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,21 +14,116 @@ final class Snapshot implements HandlesArguments
|
|||||||
{
|
{
|
||||||
use Concerns\HandleArguments;
|
use Concerns\HandleArguments;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether snapshots should be updated on this run.
|
||||||
|
*/
|
||||||
|
public static bool $updateSnapshots = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
public function handleArguments(array $arguments): array
|
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)) {
|
if (! $this->hasArgument('--update-snapshots', $arguments)) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->hasArgument('--parallel', $arguments)) {
|
self::$updateSnapshots = true;
|
||||||
throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.');
|
|
||||||
|
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);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2279
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,621 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Exceptions\BaselineFetchFailed;
|
||||||
|
use Pest\Panic;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
|
use Pest\Support\View;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class BaselineSync
|
||||||
|
{
|
||||||
|
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
||||||
|
|
||||||
|
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
||||||
|
|
||||||
|
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
||||||
|
|
||||||
|
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
||||||
|
|
||||||
|
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
|
||||||
|
|
||||||
|
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
|
||||||
|
|
||||||
|
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||||
|
|
||||||
|
private const array DIAGNOSES = [
|
||||||
|
'network' => [
|
||||||
|
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
|
||||||
|
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
||||||
|
],
|
||||||
|
'gh-auth' => [
|
||||||
|
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
|
||||||
|
'message' => 'authentication failed — run `gh auth login` and retry.',
|
||||||
|
],
|
||||||
|
'rate-limit' => [
|
||||||
|
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
|
||||||
|
'message' => 'GitHub API rate limit hit — try again later.',
|
||||||
|
],
|
||||||
|
'not-found' => [
|
||||||
|
'pattern' => '/404|not found|repository not found/i',
|
||||||
|
'message' => 'workflow or artifact not found in repo.',
|
||||||
|
],
|
||||||
|
'forbidden' => [
|
||||||
|
'pattern' => '/403|forbidden|access denied/i',
|
||||||
|
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private State $state,
|
||||||
|
private OutputInterface $output,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private function renderBadge(string $type, string $content): void
|
||||||
|
{
|
||||||
|
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderChild(string $text): void
|
||||||
|
{
|
||||||
|
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
|
||||||
|
{
|
||||||
|
$repo = $this->detectGitHubRepo($projectRoot);
|
||||||
|
|
||||||
|
if ($repo === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||||
|
$this->renderBadge('WARN', sprintf(
|
||||||
|
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
|
||||||
|
$this->formatDuration($remaining),
|
||||||
|
));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
||||||
|
$payload = $result['payload'];
|
||||||
|
$failureKind = $result['failureKind'];
|
||||||
|
|
||||||
|
if ($payload === null) {
|
||||||
|
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||||
|
$this->startCooldown();
|
||||||
|
$this->emitPublishInstructions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload['coverage'] !== null) {
|
||||||
|
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->clearCooldown();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cooldownRemaining(): ?int
|
||||||
|
{
|
||||||
|
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
|
||||||
|
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = $decoded['until'] - time();
|
||||||
|
|
||||||
|
return $remaining > 0 ? $remaining : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function startCooldown(): void
|
||||||
|
{
|
||||||
|
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
|
||||||
|
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearCooldown(): void
|
||||||
|
{
|
||||||
|
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDuration(int $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds >= 3600) {
|
||||||
|
return (int) round($seconds / 3600).'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seconds >= 60) {
|
||||||
|
return (int) round($seconds / 60).'m';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $seconds.'s';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emitPublishInstructions(): void
|
||||||
|
{
|
||||||
|
if ($this->isCi()) {
|
||||||
|
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
||||||
|
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isCi(): bool
|
||||||
|
{
|
||||||
|
return getenv('GITHUB_ACTIONS') === 'true'
|
||||||
|
|| getenv('GITLAB_CI') === 'true'
|
||||||
|
|| getenv('CIRCLECI') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectGitHubRepo(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||||
|
|
||||||
|
if (! is_file($gitConfig)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = @file_get_contents($gitConfig);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $match[1];
|
||||||
|
|
||||||
|
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
|
||||||
|
*/
|
||||||
|
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
||||||
|
{
|
||||||
|
$this->validateGhDependencies($hasAnchor);
|
||||||
|
|
||||||
|
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
||||||
|
|
||||||
|
if ($listError !== null) {
|
||||||
|
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
|
||||||
|
|
||||||
|
$this->renderBadge('WARN', sprintf(
|
||||||
|
'Failed to query baseline runs — %s',
|
||||||
|
$listError['message'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return ['payload' => null, 'failureKind' => $listError['kind']];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($runId === null) {
|
||||||
|
return ['payload' => null, 'failureKind' => 'no-runs'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
||||||
|
|
||||||
|
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
||||||
|
@touch($runCacheDir);
|
||||||
|
|
||||||
|
$this->renderChild(sprintf(
|
||||||
|
'Using cached baseline from %s (run %s).',
|
||||||
|
$repo,
|
||||||
|
$runId,
|
||||||
|
));
|
||||||
|
|
||||||
|
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
||||||
|
return ['payload' => null, 'failureKind' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
|
||||||
|
|
||||||
|
if (! $download['success']) {
|
||||||
|
return ['payload' => null, 'failureKind' => $download['failureKind']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
|
||||||
|
|
||||||
|
$this->trimDownloadCache($projectRoot);
|
||||||
|
|
||||||
|
return ['payload' => $payload, 'failureKind' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{kind: string, message: string} $diagnosis
|
||||||
|
*/
|
||||||
|
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
|
||||||
|
{
|
||||||
|
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
|
||||||
|
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
||||||
|
$hasAnchor,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateGhDependencies(bool $hasAnchor): void
|
||||||
|
{
|
||||||
|
if (! $this->commandExists('gh')) {
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||||
|
'Install it from https://cli.github.com.',
|
||||||
|
$hasAnchor,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->ghAuthenticated()) {
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
||||||
|
'Run `gh auth login` and retry.',
|
||||||
|
$hasAnchor,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, failureKind: ?string}
|
||||||
|
*/
|
||||||
|
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
|
||||||
|
{
|
||||||
|
$artifactSize = $this->artifactSize($repo, $runId);
|
||||||
|
|
||||||
|
$this->output->writeln('');
|
||||||
|
$this->renderChild($artifactSize !== null
|
||||||
|
? sprintf(
|
||||||
|
'Downloading TIA baseline (%s) from %s…',
|
||||||
|
$this->formatSize($artifactSize),
|
||||||
|
$repo,
|
||||||
|
)
|
||||||
|
: sprintf(
|
||||||
|
'Downloading TIA baseline from %s…',
|
||||||
|
$repo,
|
||||||
|
));
|
||||||
|
|
||||||
|
$process = new Process([
|
||||||
|
'gh', 'run', 'download', $runId,
|
||||||
|
'-R', $repo,
|
||||||
|
'-n', self::ARTIFACT_NAME,
|
||||||
|
'-D', $runCacheDir,
|
||||||
|
]);
|
||||||
|
$process->setTimeout(900.0);
|
||||||
|
$process->start();
|
||||||
|
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$tick = 0;
|
||||||
|
|
||||||
|
while ($process->isRunning()) {
|
||||||
|
$this->renderDownloadProgress($startedAt, $tick++);
|
||||||
|
usleep(120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$process->wait();
|
||||||
|
$this->clearProgressLine();
|
||||||
|
|
||||||
|
if ($process->isSuccessful()) {
|
||||||
|
return ['success' => true, 'failureKind' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cleanup($runCacheDir);
|
||||||
|
|
||||||
|
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||||
|
|
||||||
|
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
|
||||||
|
|
||||||
|
$this->renderBadge('WARN', sprintf(
|
||||||
|
'Baseline download failed — %s',
|
||||||
|
$diagnosis['message'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return ['success' => false, 'failureKind' => $diagnosis['kind']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
|
||||||
|
*/
|
||||||
|
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
|
||||||
|
{
|
||||||
|
$payload = $this->readArtifact($runCacheDir);
|
||||||
|
|
||||||
|
if ($payload === null) {
|
||||||
|
$this->cleanup($runCacheDir);
|
||||||
|
|
||||||
|
Panic::with(new BaselineFetchFailed(
|
||||||
|
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
||||||
|
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
||||||
|
$hasAnchor,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function artifactSize(string $repo, string $runId): ?int
|
||||||
|
{
|
||||||
|
$process = new Process([
|
||||||
|
'gh', 'api',
|
||||||
|
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
||||||
|
'--jq', sprintf(
|
||||||
|
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
|
||||||
|
self::ARTIFACT_NAME,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
$process->setTimeout(30.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = trim($process->getOutput());
|
||||||
|
|
||||||
|
return is_numeric($size) ? (int) $size : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderDownloadProgress(float $startedAt, int $tick): void
|
||||||
|
{
|
||||||
|
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
|
||||||
|
$elapsed = max(0.0, microtime(true) - $startedAt);
|
||||||
|
$frame = $frames[$tick % count($frames)];
|
||||||
|
|
||||||
|
$this->output->write(sprintf(
|
||||||
|
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
|
||||||
|
$frame,
|
||||||
|
$elapsed,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearProgressLine(): void
|
||||||
|
{
|
||||||
|
$this->output->write("\r\033[K");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dirSize(string $dir): int
|
||||||
|
{
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var \SplFileInfo $entry */
|
||||||
|
foreach ($iterator as $entry) {
|
||||||
|
if ($entry->isFile()) {
|
||||||
|
$total += $entry->getSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
|
||||||
|
*/
|
||||||
|
private function readArtifact(string $dir): ?array
|
||||||
|
{
|
||||||
|
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
||||||
|
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
||||||
|
|
||||||
|
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
|
||||||
|
|
||||||
|
if ($graph === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'graph' => $graph,
|
||||||
|
'coverage' => $coverage === false ? null : $coverage,
|
||||||
|
'sizeOnDisk' => $this->dirSize($dir),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadCacheDir(string $projectRoot): string
|
||||||
|
{
|
||||||
|
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeRunId(string $runId): string
|
||||||
|
{
|
||||||
|
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
||||||
|
|
||||||
|
return $sanitised === '' ? 'unknown' : $sanitised;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trimDownloadCache(string $projectRoot): void
|
||||||
|
{
|
||||||
|
$root = $this->downloadCacheDir($projectRoot);
|
||||||
|
|
||||||
|
if (! is_dir($root)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = @scandir($root);
|
||||||
|
|
||||||
|
if ($entries === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (in_array($entry, ['.', '..'], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||||
|
|
||||||
|
if (! is_dir($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = @filemtime($path);
|
||||||
|
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$candidates,
|
||||||
|
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
|
||||||
|
$this->cleanup($stale['path']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
||||||
|
*/
|
||||||
|
private function latestSuccessfulRunIdWithError(string $repo): array
|
||||||
|
{
|
||||||
|
$process = new Process([
|
||||||
|
'gh', 'run', 'list',
|
||||||
|
'-R', $repo,
|
||||||
|
'--workflow', self::WORKFLOW_FILE,
|
||||||
|
'--status', 'success',
|
||||||
|
'--limit', '1',
|
||||||
|
'--json', 'databaseId',
|
||||||
|
'--jq', '.[0].databaseId // empty',
|
||||||
|
]);
|
||||||
|
$process->setTimeout(30.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
|
||||||
|
}
|
||||||
|
|
||||||
|
$runId = trim($process->getOutput());
|
||||||
|
|
||||||
|
return [$runId === '' ? null : $runId, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ghAuthenticated(): bool
|
||||||
|
{
|
||||||
|
$process = new Process(['gh', 'auth', 'status']);
|
||||||
|
$process->setTimeout(10.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
return $process->isSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{kind: string, message: string}
|
||||||
|
*/
|
||||||
|
private function classifyGhError(string $output): array
|
||||||
|
{
|
||||||
|
$output = trim($output);
|
||||||
|
|
||||||
|
if ($output === '') {
|
||||||
|
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::DIAGNOSES as $kind => $diagnosis) {
|
||||||
|
if (preg_match($diagnosis['pattern'], $output) === 1) {
|
||||||
|
return ['kind' => $kind, 'message' => $diagnosis['message']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function commandExists(string $cmd): bool
|
||||||
|
{
|
||||||
|
$process = new Process(['which', $cmd]);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
return $process->isSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanup(string $dir): void
|
||||||
|
{
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var \SplFileInfo $entry */
|
||||||
|
foreach ($iterator as $entry) {
|
||||||
|
if ($entry->isDir()) {
|
||||||
|
@rmdir($entry->getPathname());
|
||||||
|
} else {
|
||||||
|
@unlink($entry->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatSize(int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes >= 1024 * 1024) {
|
||||||
|
return sprintf('%.1f MB', $bytes / 1024 / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes >= 1024) {
|
||||||
|
return sprintf('%.1f KB', $bytes / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes.' B';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
||||||
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
|
use Pest\Support\Container;
|
||||||
|
use Pest\TestSuite;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class Bootstrapper implements BootstrapperContract
|
||||||
|
{
|
||||||
|
public function __construct(private Container $container) {}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$testSuite = $this->container->get(TestSuite::class);
|
||||||
|
assert($testSuite instanceof TestSuite);
|
||||||
|
|
||||||
|
$tempDir = Storage::tempDir($testSuite->rootPath);
|
||||||
|
|
||||||
|
$this->container->add(State::class, new FileState($tempDir));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Exceptions\MissingDependency;
|
||||||
|
use Pest\Support\Git;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class ChangedFiles
|
||||||
|
{
|
||||||
|
private Git $git;
|
||||||
|
|
||||||
|
public function __construct(private string $projectRoot)
|
||||||
|
{
|
||||||
|
$this->git = new Git($projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $files project-relative paths.
|
||||||
|
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
||||||
|
{
|
||||||
|
if ($lastRunTree === []) {
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = array_fill_keys($files, true);
|
||||||
|
|
||||||
|
foreach (array_keys($lastRunTree) as $snapshotted) {
|
||||||
|
$candidates[$snapshotted] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = [];
|
||||||
|
|
||||||
|
foreach (array_keys($candidates) as $file) {
|
||||||
|
$snapshot = $lastRunTree[$file] ?? null;
|
||||||
|
$current = $this->currentHash($file);
|
||||||
|
|
||||||
|
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
||||||
|
$remaining[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentHash(string $relativePath): ?string
|
||||||
|
{
|
||||||
|
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = ContentHash::of($absolute);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $files
|
||||||
|
* @return array<string, string> path → xxh128 content hash
|
||||||
|
*/
|
||||||
|
public function snapshotTree(array $files): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
$out[$file] = '';
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = ContentHash::of($absolute);
|
||||||
|
|
||||||
|
if ($hash !== false) {
|
||||||
|
$out[$file] = $hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||||
|
*/
|
||||||
|
public function since(?string $sha): ?array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
if ($sha !== null && $sha !== '') {
|
||||||
|
if (! $this->shaIsReachable($sha)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_merge($files, $this->diffSinceSha($sha));
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_merge($files, $this->workingTreeChanges());
|
||||||
|
|
||||||
|
$unique = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($file === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$unique[$file] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = array_keys($this->filterIgnored($unique));
|
||||||
|
|
||||||
|
if ($sha !== null && $sha !== '') {
|
||||||
|
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $files
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function filterBehaviourallyUnchanged(array $files, string $sha): array
|
||||||
|
{
|
||||||
|
$remaining = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$currentHash = $this->currentHash($file);
|
||||||
|
|
||||||
|
if ($currentHash === null) {
|
||||||
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineContent = $this->contentAtSha($sha, $file);
|
||||||
|
|
||||||
|
if ($baselineContent === null) {
|
||||||
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
|
||||||
|
$remaining[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function contentAtSha(string $sha, string $path): ?string
|
||||||
|
{
|
||||||
|
return $this->git->show($sha, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $candidates
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
private function filterIgnored(array $candidates): array
|
||||||
|
{
|
||||||
|
if ($candidates === []) {
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->git->result(
|
||||||
|
['check-ignore', '--no-index', '-z', '--stdin'],
|
||||||
|
implode("\x00", array_keys($candidates)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `check-ignore` exits 1 when nothing matched — that is not a failure.
|
||||||
|
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
|
||||||
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $result['output'];
|
||||||
|
|
||||||
|
if ($output === '') {
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
|
||||||
|
if ($ignored !== '') {
|
||||||
|
unset($candidates[$ignored]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentBranch(): ?string
|
||||||
|
{
|
||||||
|
$output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||||
|
|
||||||
|
if ($output === null) {
|
||||||
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
|
}
|
||||||
|
|
||||||
|
$branch = trim($output);
|
||||||
|
|
||||||
|
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
{
|
||||||
|
$head = $this->git->output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
||||||
|
|
||||||
|
if ($head !== null) {
|
||||||
|
$branch = preg_replace('#^origin/#', '', $head);
|
||||||
|
|
||||||
|
if (is_string($branch) && $branch !== '') {
|
||||||
|
return $branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$configured = $this->git->output(['config', '--get', 'init.defaultBranch']);
|
||||||
|
|
||||||
|
if ($configured === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $this->git->hasRef('refs/heads/'.$configured)
|
||||||
|
|| $this->git->hasRef('refs/remotes/origin/'.$configured);
|
||||||
|
|
||||||
|
return $exists ? $configured : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>|null
|
||||||
|
*/
|
||||||
|
public function branchNames(): ?array
|
||||||
|
{
|
||||||
|
$output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
|
||||||
|
|
||||||
|
if ($output === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($this->splitLines($output) as $ref) {
|
||||||
|
if (str_starts_with($ref, 'refs/heads/')) {
|
||||||
|
$names[substr($ref, strlen('refs/heads/'))] = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with($ref, 'refs/remotes/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tail = substr($ref, strlen('refs/remotes/'));
|
||||||
|
$slash = strpos($tail, '/');
|
||||||
|
|
||||||
|
if ($slash === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$branch = substr($tail, $slash + 1);
|
||||||
|
|
||||||
|
if ($branch !== '' && $branch !== 'HEAD') {
|
||||||
|
$names[$branch] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRemote(): bool
|
||||||
|
{
|
||||||
|
return $this->git->hasRemote();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRepository(): bool
|
||||||
|
{
|
||||||
|
return $this->git->isRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCommits(): bool
|
||||||
|
{
|
||||||
|
return $this->git->hasCommits();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Working-tree scans get a longer leash than metadata queries — on a large
|
||||||
|
* repository with a cold cache, `status` and `diff` are not instant.
|
||||||
|
*/
|
||||||
|
private function scan(): Git
|
||||||
|
{
|
||||||
|
return $this->git->withTimeout(60.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shaIsReachable(string $sha): bool
|
||||||
|
{
|
||||||
|
return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function diffSinceSha(string $sha): array
|
||||||
|
{
|
||||||
|
$output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
|
||||||
|
|
||||||
|
if ($output === null) {
|
||||||
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->splitLines($output);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function workingTreeChanges(): array
|
||||||
|
{
|
||||||
|
$output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
|
||||||
|
|
||||||
|
if ($output === null) {
|
||||||
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($output === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$records = explode("\x00", rtrim($output, "\x00"));
|
||||||
|
$files = [];
|
||||||
|
$count = count($records);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$record = $records[$i];
|
||||||
|
|
||||||
|
if (strlen($record) < 4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = substr($record, 0, 2);
|
||||||
|
$path = substr($record, 3);
|
||||||
|
|
||||||
|
if ($status[0] === 'R' || $status[0] === 'C') {
|
||||||
|
$files[] = $path;
|
||||||
|
|
||||||
|
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
|
||||||
|
$files[] = $records[$i + 1];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentSha(): ?string
|
||||||
|
{
|
||||||
|
$output = $this->git->raw(['rev-parse', 'HEAD']);
|
||||||
|
|
||||||
|
if ($output === null) {
|
||||||
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sha = trim($output);
|
||||||
|
|
||||||
|
return $sha === '' ? null : $sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function splitLines(string $output): array
|
||||||
|
{
|
||||||
|
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
|
||||||
|
|
||||||
|
return $lines === false ? [] : $lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Contracts\Ci;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CiDefaultBranch
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, class-string<Ci>>
|
||||||
|
*/
|
||||||
|
private const array CIS = [
|
||||||
|
Cis\GitLab::class,
|
||||||
|
Cis\GitHub::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function detect(): ?string
|
||||||
|
{
|
||||||
|
foreach (self::CIS as $class) {
|
||||||
|
$branch = (new $class)->defaultBranch();
|
||||||
|
|
||||||
|
if ($branch !== null) {
|
||||||
|
return $branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Cis\Concerns;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
trait ReadsEnvironment
|
||||||
|
{
|
||||||
|
private function environment(string $name): ?string
|
||||||
|
{
|
||||||
|
$value = getenv($name);
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Cis;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
|
||||||
|
use Pest\Plugins\Tia\Contracts\Ci;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class GitHub implements Ci
|
||||||
|
{
|
||||||
|
use ReadsEnvironment;
|
||||||
|
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
{
|
||||||
|
$path = $this->environment('GITHUB_EVENT_PATH');
|
||||||
|
|
||||||
|
if ($path === null || ! is_file($path) || ! is_readable($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($contents === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($contents, true);
|
||||||
|
|
||||||
|
if (! is_array($payload) || ! is_array($payload['repository'] ?? null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$branch = $payload['repository']['default_branch'] ?? null;
|
||||||
|
|
||||||
|
return is_string($branch) && $branch !== '' ? $branch : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Cis;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
|
||||||
|
use Pest\Plugins\Tia\Contracts\Ci;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class GitLab implements Ci
|
||||||
|
{
|
||||||
|
use ReadsEnvironment;
|
||||||
|
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
{
|
||||||
|
return $this->environment('CI_DEFAULT_BRANCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Edges\BladeEdges;
|
||||||
|
use Pest\Plugins\Tia\Edges\InertiaEdges;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class Collectors
|
||||||
|
{
|
||||||
|
/** @var list<class-string> */
|
||||||
|
private const array COLLECTORS = [
|
||||||
|
BladeEdges::class,
|
||||||
|
TableTracker::class,
|
||||||
|
InertiaEdges::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function armAll(Recorder $recorder): void
|
||||||
|
{
|
||||||
|
foreach (self::COLLECTORS as $collector) {
|
||||||
|
$collector::arm($recorder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Support\Container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class Configuration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function always(): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->markEnabled();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function locally(): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->markEnabled();
|
||||||
|
$watchPatterns->markLocally();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function filtered(): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->markFiltered();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function baselined(): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->markBaselined();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function defaultBranch(string $branch): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->setDefaultBranch($branch);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $patterns glob → project-relative test dir
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function watch(array $patterns): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->add($patterns);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContentHash
|
||||||
|
{
|
||||||
|
public static function of(string $absolute): string|false
|
||||||
|
{
|
||||||
|
$raw = @file_get_contents($absolute);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::ofContent($absolute, $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ofContent(string $path, string $raw): string
|
||||||
|
{
|
||||||
|
$lower = strtolower($path);
|
||||||
|
|
||||||
|
if (str_ends_with($lower, '.blade.php')) {
|
||||||
|
return self::hashBladeContent($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($lower, '.php')) {
|
||||||
|
return self::hashPhpContent($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['.vue', '.tsx', '.jsx', '.svelte', '.ts', '.js', '.mjs', '.cjs', '.mts'] as $extension) {
|
||||||
|
if (str_ends_with($lower, $extension)) {
|
||||||
|
return self::hashJsContent($raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('xxh128', $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hashPhpContent(string $raw): string
|
||||||
|
{
|
||||||
|
$tokens = @token_get_all($raw);
|
||||||
|
|
||||||
|
if ($tokens === []) {
|
||||||
|
return hash('xxh128', $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = '';
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (is_array($token)) {
|
||||||
|
if ($token[0] === T_WHITESPACE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($token[0] === T_COMMENT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($token[0] === T_DOC_COMMENT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalised .= $token[1];
|
||||||
|
} else {
|
||||||
|
$normalised .= $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('xxh128', $normalised);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hashBladeContent(string $raw): string
|
||||||
|
{
|
||||||
|
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
||||||
|
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
||||||
|
|
||||||
|
return hash('xxh128', trim($stripped));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hashJsContent(string $raw): string
|
||||||
|
{
|
||||||
|
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
|
||||||
|
$stripped = preg_replace('/^\s*\/\*.*?\*\/\s*$/sm', '', $stripped) ?? $stripped;
|
||||||
|
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
||||||
|
|
||||||
|
return hash('xxh128', trim($stripped));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface Ci
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The default branch advertised by this CI, or `null` when the run
|
||||||
|
* does not happen on it — or when it exposes no such information.
|
||||||
|
*/
|
||||||
|
public function defaultBranch(): ?string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user