mirror of
https://github.com/pestphp/pest.git
synced 2026-07-28 20:38:57 +02:00
Compare commits
183 Commits
6407c4f78f
...
v5.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| fcf5c27914 | |||
| 18bbca748f | |||
| f142aad8ad | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| e63a886f98 | |||
| 8dd650fd05 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| 668ca9f5de | |||
| f659a45311 | |||
| 12c1da29ee | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 |
@@ -0,0 +1,13 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
|
||||||
|
|
||||||
|
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
|
||||||
|
|
||||||
|
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
|
||||||
|
|
||||||
|
All security vulnerabilities will be promptly addressed.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 5
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
@@ -2,7 +2,7 @@ name: Static Analysis
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [4.x]
|
branches: [5.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * *'
|
- cron: '0 9 * * *'
|
||||||
@@ -24,16 +24,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
dependency-version: [prefer-lowest, prefer-stable]
|
dependency-version: [prefer-stable]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@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
|
||||||
@@ -44,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@v5
|
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', '**/composer.lock') }}
|
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||||
static-php-8.3-composer-
|
static-php-8.4-composer-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
|
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [4.x]
|
branches: [5.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * *'
|
- cron: '0 9 * * *'
|
||||||
@@ -24,21 +24,18 @@ jobs:
|
|||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||||
symfony: ['7.4', '8.0']
|
symfony: ['8.0']
|
||||||
php: ['8.3', '8.4', '8.5']
|
php: ['8.4', '8.5']
|
||||||
dependency_version: [prefer-stable]
|
dependency_version: [prefer-stable]
|
||||||
exclude:
|
|
||||||
- php: '8.3'
|
|
||||||
symfony: '8.0'
|
|
||||||
|
|
||||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@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
|
||||||
@@ -51,7 +48,7 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@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', '**/composer.lock') }}
|
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||||
@@ -66,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,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/pest-v5-is-here)**.
|
||||||
|
|
||||||
**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,12 @@
|
|||||||
|
|
||||||
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)**
|
|
||||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
|
||||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
|
||||||
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
|
||||||
|
|
||||||
### Gold Sponsors
|
|
||||||
|
|
||||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
- **[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)
|
- **[Redberry](https://redberry.international/laravel-development/)**
|
||||||
- [Nerdify](https://getnerdify.com/?ref=pestphp)
|
|
||||||
- [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"`
|
||||||
|
|||||||
+186
-17
@@ -6,33 +6,91 @@ import { createRequire } from 'node:module'
|
|||||||
import { resolve, relative, extname, sep, join } from 'node:path'
|
import { resolve, relative, extname, sep, join } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte'])
|
const PAGE_EXTENSIONS = new Set([
|
||||||
|
'.vue', '.svelte',
|
||||||
|
'.tsx', '.jsx',
|
||||||
|
'.ts', '.js',
|
||||||
|
'.mts', '.cts', '.mjs', '.cjs',
|
||||||
|
])
|
||||||
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
|
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
|
||||||
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
||||||
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
|
const PAGE_DIR_CANDIDATES = [
|
||||||
|
'resources/js/Pages',
|
||||||
|
'resources/js/pages',
|
||||||
|
'assets/js/Pages',
|
||||||
|
'assets/js/pages',
|
||||||
|
'assets/Pages',
|
||||||
|
'assets/pages',
|
||||||
|
]
|
||||||
|
|
||||||
async function loadRolldown() {
|
async function loadRolldown() {
|
||||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||||
const path = projectRequire.resolve('rolldown')
|
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)
|
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) {
|
async function readJsonWithComments(path) {
|
||||||
const raw = await readFile(path, 'utf8')
|
const raw = await readFile(path, 'utf8')
|
||||||
const stripped = raw
|
return JSON.parse(stripJsonComments(raw))
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
||||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
||||||
return JSON.parse(stripped)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAliasFromTsconfig() {
|
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||||
const alias = {}
|
const alias = {}
|
||||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||||
const p = join(PROJECT_ROOT, name)
|
const p = join(projectRoot, name)
|
||||||
if (!existsSync(p)) continue
|
if (!existsSync(p)) continue
|
||||||
let cfg
|
let cfg
|
||||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||||
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
|
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||||
for (const [key, targets] of Object.entries(paths)) {
|
for (const [key, targets] of Object.entries(paths)) {
|
||||||
if (!key.endsWith('/*')) continue
|
if (!key.endsWith('/*')) continue
|
||||||
@@ -46,6 +104,74 @@ async function loadAliasFromTsconfig() {
|
|||||||
return alias
|
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) {
|
async function listPageFiles(pagesDir) {
|
||||||
if (!existsSync(pagesDir)) return []
|
if (!existsSync(pagesDir)) return []
|
||||||
|
|
||||||
@@ -64,6 +190,22 @@ async function listPageFiles(pagesDir) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function discoverPagesDir() {
|
||||||
|
const override = process.env.TIA_VITE_PAGES_DIR
|
||||||
|
if (override && override.length > 0) {
|
||||||
|
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rel of PAGE_DIR_CANDIDATES) {
|
||||||
|
const abs = resolve(PROJECT_ROOT, rel)
|
||||||
|
if (!existsSync(abs)) continue
|
||||||
|
const files = await listPageFiles(abs)
|
||||||
|
if (files.length > 0) return abs
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function componentNameFor(pageAbs, pagesDir) {
|
function componentNameFor(pageAbs, pagesDir) {
|
||||||
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
||||||
const ext = extname(rel)
|
const ext = extname(rel)
|
||||||
@@ -79,7 +221,13 @@ function isLocalSpecifier(source, aliasKeys) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
|
const pagesDir = await discoverPagesDir()
|
||||||
|
|
||||||
|
if (pagesDir === null) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pages = await listPageFiles(pagesDir)
|
const pages = await listPageFiles(pagesDir)
|
||||||
|
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
@@ -87,8 +235,16 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rolldown } = await loadRolldown()
|
const loaded = await loadRolldown()
|
||||||
const alias = await loadAliasFromTsconfig()
|
|
||||||
|
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 aliasKeys = Object.keys(alias)
|
||||||
|
|
||||||
const graph = new Map()
|
const graph = new Map()
|
||||||
@@ -133,7 +289,7 @@ async function main() {
|
|||||||
cwd: PROJECT_ROOT,
|
cwd: PROJECT_ROOT,
|
||||||
resolve: {
|
resolve: {
|
||||||
alias,
|
alias,
|
||||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
|
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||||
},
|
},
|
||||||
transform: { jsx: 'preserve' },
|
transform: { jsx: 'preserve' },
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
@@ -158,6 +314,11 @@ async function main() {
|
|||||||
|
|
||||||
stack.add(id)
|
stack.add(id)
|
||||||
const acc = new Set()
|
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)
|
const deps = graph.get(id)
|
||||||
if (deps) {
|
if (deps) {
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
@@ -166,13 +327,17 @@ async function main() {
|
|||||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||||
acc.add(rel)
|
acc.add(rel)
|
||||||
}
|
}
|
||||||
if (stack.has(dep)) continue
|
if (stack.has(dep)) {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
const child = computeTransitive(dep, stack)
|
const child = computeTransitive(dep, stack)
|
||||||
if (child) for (const r of child) acc.add(r)
|
if (child) for (const r of child) acc.add(r)
|
||||||
|
if (!transitiveCache.has(dep)) complete = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stack.delete(id)
|
stack.delete(id)
|
||||||
transitiveCache.set(id, acc)
|
if (complete) transitiveCache.set(id, acc)
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,10 +361,14 @@ async function main() {
|
|||||||
process.stdout.write(JSON.stringify(payload))
|
process.stdout.write(JSON.stringify(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invokedDirectly = process.argv[1] !== undefined
|
||||||
|
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
|
|
||||||
|
if (invokedDirectly) {
|
||||||
try {
|
try {
|
||||||
void pathToFileURL
|
|
||||||
await main()
|
await main()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||||
process.exit(1)
|
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));
|
||||||
|
|||||||
+18
-20
@@ -17,22 +17,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3.0",
|
"php": "^8.4",
|
||||||
"brianium/paratest": "^7.20.0",
|
"brianium/paratest": "^7.23.0",
|
||||||
"composer/xdebug-handler": "^3.0.5",
|
"nunomaduro/collision": "^8.9.5",
|
||||||
"fidry/cpu-core-counter": "^1.3",
|
|
||||||
"nunomaduro/collision": "^8.9.4",
|
|
||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^4.0.0",
|
"pestphp/pest-plugin": "^5.0.0",
|
||||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
"pestphp/pest-plugin-mutate": "^5.0.0",
|
||||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||||
"phpunit/phpunit": "^12.5.24",
|
"phpunit/phpunit": "^13.2.4",
|
||||||
"symfony/process": "^7.4.8|^8.0.8"
|
"symfony/process": "^8.1.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"filp/whoops": "<2.18.3",
|
"filp/whoops": "<2.18.3",
|
||||||
"phpunit/phpunit": ">12.5.24",
|
"phpunit/phpunit": ">13.2.4",
|
||||||
"sebastian/exporter": "<7.0.0",
|
"sebastian/exporter": "<7.0.0",
|
||||||
"webmozart/assert": "<1.11.0"
|
"webmozart/assert": "<1.11.0"
|
||||||
},
|
},
|
||||||
@@ -60,11 +58,13 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"mrpunyapal/peststan": "^0.2.9",
|
"laravel/pao": "^1.1.2",
|
||||||
"pestphp/pest-dev-tools": "^4.1.0",
|
"pestphp/pest-dev-tools": "^5.0.0",
|
||||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||||
"psy/psysh": "^0.12.22"
|
"pestphp/pest-plugin-rector": "^5.0.0",
|
||||||
|
"pestphp/pest-plugin-type-coverage": "^5.0.0",
|
||||||
|
"psy/psysh": "^0.12.24"
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
@@ -94,7 +94,6 @@
|
|||||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||||
"test:integration": "php bin/pest --group=integration -v",
|
"test:integration": "php bin/pest --group=integration -v",
|
||||||
"test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml",
|
|
||||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||||
"test": [
|
"test": [
|
||||||
"@test:lint",
|
"@test:lint",
|
||||||
@@ -102,8 +101,7 @@
|
|||||||
"@test:type:coverage",
|
"@test:type:coverage",
|
||||||
"@test:unit",
|
"@test:unit",
|
||||||
"@test:parallel",
|
"@test:parallel",
|
||||||
"@test:integration",
|
"@test:integration"
|
||||||
"@test:tia"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
-1
@@ -1,7 +1,7 @@
|
|||||||
includes:
|
includes:
|
||||||
- phpstan-baseline.neon
|
- phpstan-baseline.neon
|
||||||
- phpstan-pest-extension.neon
|
- phpstan-pest-extension.neon
|
||||||
- vendor/mrpunyapal/peststan/extension.neon
|
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
level: 7
|
level: 7
|
||||||
|
|||||||
+38
@@ -2,22 +2,60 @@
|
|||||||
|
|
||||||
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',
|
||||||
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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',
|
||||||
|
|||||||
@@ -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,8 +25,9 @@ final readonly class BootSubscribers implements Bootstrapper
|
|||||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||||
Subscribers\EnsureTeamCityEnabled::class,
|
Subscribers\EnsureTeamCityEnabled::class,
|
||||||
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
||||||
Subscribers\EnsureTiaCoverageIsFlushed::class,
|
Subscribers\EnsureTiaStarts::class,
|
||||||
|
Subscribers\EnsureTiaEnds::class,
|
||||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
||||||
|
|||||||
+42
-48
@@ -9,9 +9,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
|
|||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Plugins\Tia\Collectors;
|
use Pest\Plugins\Tia\Collectors;
|
||||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
use Pest\Plugins\Tia\Enums\ReplayType;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Plugins\Tia\Replay;
|
|
||||||
use Pest\Preset;
|
use Pest\Preset;
|
||||||
use Pest\Support\ChainableClosure;
|
use Pest\Support\ChainableClosure;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
@@ -24,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
|
|||||||
use PHPUnit\Framework\IncompleteTest;
|
use PHPUnit\Framework\IncompleteTest;
|
||||||
use PHPUnit\Framework\SkippedTest;
|
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;
|
||||||
@@ -83,10 +84,15 @@ trait Testable
|
|||||||
public bool $__ran = false;
|
public bool $__ran = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set when a `BeforeEachable` plugin returns a cached success result.
|
* The active replay mode for this test, set in `setUp()` and checked
|
||||||
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
|
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
||||||
*/
|
*/
|
||||||
private bool $__cachedPass = false;
|
private ReplayType $__replay = ReplayType::None;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cached assertion count to replay, captured when entering replay mode.
|
||||||
|
*/
|
||||||
|
private int $__replayAssertions = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The test's test closure.
|
* The test's test closure.
|
||||||
@@ -240,8 +246,6 @@ trait Testable
|
|||||||
{
|
{
|
||||||
TestSuite::getInstance()->test = $this;
|
TestSuite::getInstance()->test = $this;
|
||||||
|
|
||||||
$this->__cachedPass = false;
|
|
||||||
|
|
||||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||||
|
|
||||||
$description = $method->description;
|
$description = $method->description;
|
||||||
@@ -277,17 +281,16 @@ trait Testable
|
|||||||
/** @var Tia $tia */
|
/** @var Tia $tia */
|
||||||
$tia = Container::getInstance()->get(Tia::class);
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
||||||
$replay = Replay::fromStatus($status);
|
$replay = ReplayType::fromStatus($status);
|
||||||
|
|
||||||
if ($replay !== Replay::No) {
|
if ($replay !== ReplayType::None) {
|
||||||
assert($status !== null);
|
assert($status !== null);
|
||||||
|
|
||||||
match ($replay) {
|
match ($replay) {
|
||||||
Replay::Pass => $this->__shortCircuitCachedPass(),
|
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||||
Replay::Skipped => $this->markTestSkipped($status->message()),
|
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||||
Replay::Incomplete => $this->markTestIncomplete($status->message()),
|
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
|
||||||
Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
||||||
Replay::No => null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -300,10 +303,6 @@ trait Testable
|
|||||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
$autoloadBeforeSetUp = $recorder->isActive()
|
|
||||||
? AutoloadEdges::snapshot()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
Collectors::armAll($recorder);
|
Collectors::armAll($recorder);
|
||||||
@@ -315,23 +314,12 @@ trait Testable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->__callClosure($beforeEach, $arguments);
|
$this->__callClosure($beforeEach, $arguments);
|
||||||
|
|
||||||
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
|
||||||
$recorder->linkSourcesForTest(
|
|
||||||
self::$__filename,
|
|
||||||
AutoloadEdges::newProjectFiles(
|
|
||||||
$autoloadBeforeSetUp,
|
|
||||||
AutoloadEdges::snapshot(),
|
|
||||||
TestSuite::getInstance()->rootPath,
|
|
||||||
self::$__filename,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function __shortCircuitCachedPass(): void
|
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||||
{
|
{
|
||||||
$this->__cachedPass = true;
|
$this->__replay = $replay;
|
||||||
|
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||||
$this->__ran = true;
|
$this->__ran = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +355,7 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
protected function tearDown(...$arguments): void
|
protected function tearDown(...$arguments): void
|
||||||
{
|
{
|
||||||
if ($this->__cachedPass) {
|
if ($this->__replay !== ReplayType::None) {
|
||||||
TestSuite::getInstance()->test = null;
|
TestSuite::getInstance()->test = null;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -398,19 +386,12 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __runTest(Closure $closure, ...$args): mixed
|
private function __runTest(Closure $closure, ...$args): mixed
|
||||||
{
|
{
|
||||||
if ($this->__cachedPass) {
|
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
|
||||||
// Feed the exact assertion count captured during the recorded
|
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
||||||
// run so Pest's "Tests: N passed (M assertions)" banner stays
|
|
||||||
// accurate on replay instead of collapsing to 1-per-test.
|
|
||||||
/** @var Tia $tia */
|
|
||||||
$tia = Container::getInstance()->get(Tia::class);
|
|
||||||
$assertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
|
||||||
|
|
||||||
if ($assertions === 0) {
|
|
||||||
$this->expectNotToPerformAssertions();
|
$this->expectNotToPerformAssertions();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->addToAssertionCount($assertions);
|
$this->addToAssertionCount($this->__replayAssertions);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -452,10 +433,15 @@ trait Testable
|
|||||||
unset($this->{$property});
|
unset($this->{$property});
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||||
|
|
||||||
if ($hasOutputExpectation) {
|
if ($outputBuffer->hasExpectation()) {
|
||||||
ob_clean();
|
ob_clean();
|
||||||
|
|
||||||
|
Closure::bind(function (): void {
|
||||||
|
$this->expectedString = null;
|
||||||
|
$this->expectedRegularExpression = null;
|
||||||
|
}, $outputBuffer, OutputBuffer::class)();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->setUp();
|
$this->setUp();
|
||||||
@@ -471,7 +457,9 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __isExpectedException(Throwable $e): bool
|
private function __isExpectedException(Throwable $e): bool
|
||||||
{
|
{
|
||||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||||
|
|
||||||
|
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||||
|
|
||||||
$expectedClass = $read('expectedException');
|
$expectedClass = $read('expectedException');
|
||||||
|
|
||||||
@@ -479,13 +467,19 @@ trait Testable
|
|||||||
return $e instanceof $expectedClass;
|
return $e instanceof $expectedClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedMessage = $read('expectedExceptionMessage');
|
$expectedMessage = $read('expectedMessage');
|
||||||
|
|
||||||
if ($expectedMessage !== null) {
|
if ($expectedMessage !== null) {
|
||||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedCode = $read('expectedExceptionCode');
|
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||||
|
|
||||||
|
if ($expectedMessageRegex !== null) {
|
||||||
|
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedCode = $read('expectedCode');
|
||||||
|
|
||||||
if ($expectedCode !== null) {
|
if ($expectedCode !== null) {
|
||||||
return $e->getCode() === $expectedCode;
|
return $e->getCode() === $expectedCode;
|
||||||
|
|||||||
+11
-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,14 @@ 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.
|
* Gets the TIA (Test Impact Analysis) configuration.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Pest\Contracts\Panicable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $subdirectoryPrefix)
|
||||||
|
{
|
||||||
|
parent::__construct(sprintf(
|
||||||
|
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||||
|
$this->subdirectoryPrefix,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(OutputInterface $output): void
|
||||||
|
{
|
||||||
|
$output->writeln([
|
||||||
|
'',
|
||||||
|
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
|
||||||
|
'',
|
||||||
|
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
|
||||||
|
'',
|
||||||
|
' Give the project its own git repository to use Tia.',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exitCode(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-17
@@ -112,7 +112,7 @@ final class Expectation
|
|||||||
if (function_exists('dump')) {
|
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;
|
||||||
@@ -120,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);
|
||||||
}
|
}
|
||||||
@@ -238,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;
|
||||||
@@ -915,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')),
|
||||||
);
|
);
|
||||||
@@ -1138,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')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,7 +94,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);
|
||||||
|
|
||||||
@@ -149,6 +150,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
|
||||||
@@ -158,6 +161,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;
|
||||||
|
|
||||||
@@ -166,7 +170,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
|
||||||
}
|
}
|
||||||
@@ -197,7 +201,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);
|
||||||
|
|||||||
@@ -235,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,
|
||||||
@@ -261,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;
|
||||||
|
|||||||
+20
-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,7 @@ final readonly class Kernel
|
|||||||
*/
|
*/
|
||||||
private const array BOOTSTRAPPERS = [
|
private const array BOOTSTRAPPERS = [
|
||||||
Bootstrappers\BootOverrides::class,
|
Bootstrappers\BootOverrides::class,
|
||||||
|
Bootstrappers\BootPhpUnitConfiguration::class,
|
||||||
Plugins\Tia\Bootstrapper::class,
|
Plugins\Tia\Bootstrapper::class,
|
||||||
Bootstrappers\BootSubscribers::class,
|
Bootstrappers\BootSubscribers::class,
|
||||||
Bootstrappers\BootFiles::class,
|
Bootstrappers\BootFiles::class,
|
||||||
@@ -59,12 +65,7 @@ final readonly class Kernel
|
|||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -125,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,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);
|
||||||
@@ -152,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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-21
@@ -15,11 +15,6 @@ use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
|||||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\ConsideredRisky;
|
|
||||||
use PHPUnit\Event\Test\Errored;
|
|
||||||
use PHPUnit\Event\Test\Failed;
|
|
||||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
|
||||||
use PHPUnit\Event\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;
|
||||||
@@ -254,25 +249,29 @@ final readonly class Converter
|
|||||||
...$result->testMarkedIncompleteEvents(),
|
...$result->testMarkedIncompleteEvents(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$numberOfNotPassedTests = count(
|
$notPassedTests = [];
|
||||||
array_unique(
|
|
||||||
array_map(
|
foreach ($events as $event) {
|
||||||
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|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;
|
||||||
|| $event instanceof BeforeFirstTestMethodFailed
|
}
|
||||||
|| $event instanceof AfterLastTestMethodFailed) {
|
if ($event instanceof AfterLastTestMethodFailed) {
|
||||||
return $event->testClassName();
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
-1
@@ -936,7 +936,7 @@ final class Expectation
|
|||||||
|
|
||||||
if ($exception instanceof Closure) {
|
if ($exception instanceof Closure) {
|
||||||
$callback = $exception;
|
$callback = $exception;
|
||||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||||
|
|
||||||
if (count($parameters) !== 1) {
|
if (count($parameters) !== 1) {
|
||||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||||
@@ -954,6 +954,7 @@ final class Expectation
|
|||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
|
||||||
if ($exception instanceof Throwable) {
|
if ($exception instanceof Throwable) {
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
expect($e)
|
expect($e)
|
||||||
->toBeInstanceOf($exception::class, $message)
|
->toBeInstanceOf($exception::class, $message)
|
||||||
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
||||||
@@ -1141,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
|
||||||
*
|
*
|
||||||
@@ -1154,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
|
||||||
*
|
*
|
||||||
@@ -1186,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use PHPStan\Type\Type;
|
|||||||
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
||||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||||
*
|
*
|
||||||
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
|
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
|
|||||||
|
|
||||||
$varType = $scope->getType($expr->var);
|
$varType = $scope->getType($expr->var);
|
||||||
|
|
||||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.6.3';
|
return '5.0.0-beta.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
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,11 +50,14 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ 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';
|
||||||
@@ -77,11 +79,9 @@ 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);
|
||||||
|
|||||||
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
|
|||||||
{
|
{
|
||||||
$arguments = new ArgvInput;
|
$arguments = new ArgvInput;
|
||||||
|
|
||||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -120,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ 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\Result;
|
use Pest\Result;
|
||||||
@@ -25,11 +25,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,6 +43,8 @@ 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 str_starts_with;
|
||||||
@@ -49,14 +57,14 @@ use function usleep;
|
|||||||
*/
|
*/
|
||||||
final class WrapperRunner implements RunnerInterface
|
final class WrapperRunner implements RunnerInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* The time to sleep between cycles.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* The merged test result from the parallel run.
|
* The merged test result from the parallel run.
|
||||||
*/
|
*/
|
||||||
public static ?TestResult $result = null;
|
public static ?TestResult $result = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time to sleep between cycles.
|
||||||
|
*/
|
||||||
private const int CYCLE_SLEEP = 10000;
|
private const int CYCLE_SLEEP = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +91,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 = [];
|
||||||
|
|
||||||
@@ -146,7 +166,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();
|
||||||
@@ -213,7 +232,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) {
|
||||||
@@ -228,6 +247,18 @@ 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,
|
||||||
@@ -275,10 +306,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;
|
||||||
}
|
}
|
||||||
@@ -311,6 +347,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;
|
||||||
@@ -345,6 +395,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()),
|
||||||
@@ -380,8 +444,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(),
|
||||||
@@ -417,8 +488,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);
|
||||||
@@ -433,6 +507,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,
|
||||||
@@ -448,10 +536,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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
+105
-12
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
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.
|
||||||
*
|
*
|
||||||
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
self::$timeBalanced = true;
|
self::$timeBalanced = true;
|
||||||
self::$shardsOutdated = $newTests !== [];
|
self::$shardsOutdated = $newTests !== [];
|
||||||
} else {
|
} else {
|
||||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||||
|
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$shard = [
|
self::$shard = [
|
||||||
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
$filter = $this->buildFilterArgument($testsToRun);
|
||||||
|
|
||||||
|
$this->ensureFilterLengthIsSafe($filter);
|
||||||
|
|
||||||
|
return [...$arguments, '--filter', $filter];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function allTests(array $arguments): array
|
private function allTests(array $arguments): array
|
||||||
{
|
{
|
||||||
$output = (new Process([
|
$command = $this->buildListTestsCommand(
|
||||||
'php',
|
$arguments,
|
||||||
...$this->removeParallelArguments($arguments),
|
TestSuite::getInstance()->testPath,
|
||||||
'--list-tests',
|
);
|
||||||
]))->setTimeout(120)->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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
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
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+329
-161
@@ -9,7 +9,9 @@ use Pest\Contracts\Plugins\AddsOutput;
|
|||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
use Pest\Contracts\Plugins\Terminable;
|
use Pest\Contracts\Plugins\Terminable;
|
||||||
use Pest\Exceptions\NoAffectedTestsFound;
|
use Pest\Exceptions\NoAffectedTestsFound;
|
||||||
|
use Pest\Exceptions\TiaRequiresRepositoryRoot;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
|
use Pest\Plugins\Concerns\HandleArguments;
|
||||||
use Pest\Plugins\Tia\BaselineSync;
|
use Pest\Plugins\Tia\BaselineSync;
|
||||||
use Pest\Plugins\Tia\ChangedFiles;
|
use Pest\Plugins\Tia\ChangedFiles;
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
@@ -19,6 +21,7 @@ use Pest\Plugins\Tia\Graph;
|
|||||||
use Pest\Plugins\Tia\JsModuleGraph;
|
use Pest\Plugins\Tia\JsModuleGraph;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
use Pest\Plugins\Tia\ResultCollector;
|
||||||
|
use Pest\Plugins\Tia\SourceScope;
|
||||||
use Pest\Plugins\Tia\Storage;
|
use Pest\Plugins\Tia\Storage;
|
||||||
use Pest\Plugins\Tia\TableExtractor;
|
use Pest\Plugins\Tia\TableExtractor;
|
||||||
use Pest\Plugins\Tia\WatchPatterns;
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
@@ -29,23 +32,38 @@ use Pest\TestSuite;
|
|||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Process\Process;
|
use Symfony\Component\Process\Process;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||||
{
|
{
|
||||||
use Concerns\HandleArguments;
|
use HandleArguments;
|
||||||
|
|
||||||
private const string OPTION = '--tia';
|
private const string OPTION = '--tia';
|
||||||
|
|
||||||
|
private const string NO_OPTION = '--no-tia';
|
||||||
|
|
||||||
private const string FRESH_OPTION = '--fresh';
|
private const string FRESH_OPTION = '--fresh';
|
||||||
|
|
||||||
private const string REFETCH_OPTION = '--refetch';
|
private const string REFETCH_OPTION = '--refetch';
|
||||||
|
|
||||||
private const string FILTERED_OPTION = '--filtered';
|
private const string FILTERED_OPTION = '--filtered';
|
||||||
|
|
||||||
|
private const string LOCALLY_OPTION = '--locally';
|
||||||
|
|
||||||
|
private const string BASELINED_OPTION = '--baselined';
|
||||||
|
|
||||||
|
private const string BASELINE_PATH_OPTION = '--baseline';
|
||||||
|
|
||||||
|
private const string ENV_TIA = 'PEST_TIA';
|
||||||
|
|
||||||
|
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
|
||||||
|
|
||||||
|
private const string ENV_LOCALLY = 'PEST_TIA_LOCALLY';
|
||||||
|
|
||||||
|
private const string ENV_BASELINED = 'PEST_TIA_BASELINED';
|
||||||
|
|
||||||
public const string KEY_GRAPH = 'graph.json';
|
public const string KEY_GRAPH = 'graph.json';
|
||||||
|
|
||||||
public const string KEY_AFFECTED = 'affected.json';
|
public const string KEY_AFFECTED = 'affected.json';
|
||||||
@@ -70,6 +88,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
|
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHPUnit/Pest CLI flags whose subsequent argument is a value, not a path.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const array VALUE_TAKING_FLAGS = [
|
||||||
|
'-c', '--configuration', '--bootstrap', '--cache-directory',
|
||||||
|
'--filter', '--group', '--exclude-group', '--covers', '--uses',
|
||||||
|
'--test-suffix', '--testsuite', '--exclude-testsuite',
|
||||||
|
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
|
||||||
|
'--include-path', '--whitelist',
|
||||||
|
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
||||||
|
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
|
||||||
|
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
|
||||||
|
'--coverage-filter', '--path-coverage',
|
||||||
|
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
||||||
|
'--compact', '--ci-build-id', '--min',
|
||||||
|
];
|
||||||
|
|
||||||
private bool $graphWritten = false;
|
private bool $graphWritten = false;
|
||||||
|
|
||||||
private bool $replayRan = false;
|
private bool $replayRan = false;
|
||||||
@@ -93,16 +130,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
|
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
|
||||||
private ?array $startFingerprint = null;
|
private ?array $startFingerprint = null;
|
||||||
|
|
||||||
private function workerEdgesKey(string $token): string
|
|
||||||
{
|
|
||||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workerResultsKey(string $token): string
|
|
||||||
{
|
|
||||||
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool $piggybackCoverage = false;
|
private bool $piggybackCoverage = false;
|
||||||
|
|
||||||
private bool $recordingActive = false;
|
private bool $recordingActive = false;
|
||||||
@@ -115,6 +142,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private bool $filteredMode = false;
|
private bool $filteredMode = false;
|
||||||
|
|
||||||
|
private ?string $driftLabel = null;
|
||||||
|
|
||||||
|
private ?string $driftDetails = null;
|
||||||
|
|
||||||
|
private ?string $freshGraphReason = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OutputInterface $output,
|
private readonly OutputInterface $output,
|
||||||
private readonly Recorder $recorder,
|
private readonly Recorder $recorder,
|
||||||
@@ -171,18 +204,68 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
public static function isEnabledForRun(array $arguments): bool
|
public static function isEnabledForRun(array $arguments): bool
|
||||||
{
|
{
|
||||||
if (in_array(self::OPTION, $arguments, true)) {
|
if (self::argumentPresent(self::NO_OPTION, $arguments)) {
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
assert($watchPatterns instanceof WatchPatterns);
|
assert($watchPatterns instanceof WatchPatterns);
|
||||||
|
|
||||||
|
self::applyWatchPatternMarks($arguments, $watchPatterns);
|
||||||
|
|
||||||
|
if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $watchPatterns->isEnabled()) {
|
if (! $watchPatterns->isEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (! $watchPatterns->isLocally()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
|
return ! self::argumentPresent('--ci', $arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
|
||||||
|
{
|
||||||
|
if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) {
|
||||||
|
$watchPatterns->markEnabled();
|
||||||
|
$watchPatterns->markLocally();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::argumentPresent(self::BASELINED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_BASELINED)) {
|
||||||
|
$watchPatterns->markBaselined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors {@see HandleArguments::hasArgument()} for
|
||||||
|
* use from static contexts — matches both `--flag` and `--flag=value`.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $arguments
|
||||||
|
*/
|
||||||
|
private static function argumentPresent(string $argument, array $arguments): bool
|
||||||
|
{
|
||||||
|
foreach ($arguments as $arg) {
|
||||||
|
if ($arg === $argument) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with((string) $arg, "$argument=")) { // @phpstan-ignore-line
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function envFlagEnabled(string $name): bool
|
||||||
|
{
|
||||||
|
return filter_var(getenv($name), FILTER_VALIDATE_BOOL);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getStatus(string $filename, string $testId): ?TestStatus
|
public function getStatus(string $filename, string $testId): ?TestStatus
|
||||||
@@ -213,7 +296,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||||
|
|
||||||
if ($result instanceof TestStatus) {
|
if ($result instanceof TestStatus) {
|
||||||
if ($result->isFailure() || $result->isError()) {
|
if ($this->replayGraph->shouldRerunStatus($result)) {
|
||||||
$this->executedCount++;
|
$this->executedCount++;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -239,25 +322,45 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
public function handleArguments(array $arguments): array
|
public function handleArguments(array $arguments): array
|
||||||
{
|
{
|
||||||
|
if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) {
|
||||||
|
$this->output->writeln(Storage::tempDir(TestSuite::getInstance()->rootPath));
|
||||||
|
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
$isWorker = Parallel::isWorker();
|
$isWorker = Parallel::isWorker();
|
||||||
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
|
||||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||||
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
/** @var WatchPatterns $watchPatterns */
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
$cliEnabled = $this->hasArgument(self::OPTION, $arguments);
|
self::applyWatchPatternMarks($arguments, $watchPatterns);
|
||||||
|
$disabled = $this->hasArgument(self::NO_OPTION, $arguments);
|
||||||
|
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
|
||||||
$alwaysEnabled = $watchPatterns->isEnabled()
|
$alwaysEnabled = $watchPatterns->isEnabled()
|
||||||
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
|
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
|
||||||
$enabled = $cliEnabled || $alwaysEnabled;
|
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
|
||||||
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered())
|
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
|
||||||
&& ! $this->hasExplicitPathArgument($arguments);
|
&& ! $this->hasExplicitPathArgument($arguments)
|
||||||
|
&& ! $this->coverageReportActive();
|
||||||
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
|
||||||
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
|
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
|
||||||
|
|
||||||
$arguments = $this->popArgument(self::OPTION, $arguments);
|
$arguments = $this->popArgument(self::OPTION, $arguments);
|
||||||
|
$arguments = $this->popArgument(self::NO_OPTION, $arguments);
|
||||||
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
|
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
|
||||||
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
|
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
|
||||||
$arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
|
$arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
|
||||||
|
$arguments = $this->popArgument(self::LOCALLY_OPTION, $arguments);
|
||||||
|
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
|
||||||
|
|
||||||
|
if ($disabled) {
|
||||||
|
$this->forceRefetch = false;
|
||||||
|
$this->filteredMode = false;
|
||||||
|
$this->freshRebuild = false;
|
||||||
|
|
||||||
|
return $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
||||||
$this->freshRebuild = $forceRebuild;
|
$this->freshRebuild = $forceRebuild;
|
||||||
@@ -299,7 +402,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
$perTest = $this->piggybackCoverage
|
$perTest = $this->piggybackCoverage
|
||||||
? $this->coverageCollector->perTestFiles()
|
? $this->mergePerTestFiles($this->coverageCollector->perTestFiles(), $recorder->perTestFiles())
|
||||||
: $recorder->perTestFiles();
|
: $recorder->perTestFiles();
|
||||||
|
|
||||||
if ($perTest === []) {
|
if ($perTest === []) {
|
||||||
@@ -429,67 +532,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
$mergedFiles = [];
|
[$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys);
|
||||||
$mergedTables = [];
|
|
||||||
$mergedInertia = [];
|
|
||||||
|
|
||||||
foreach ($partialKeys as $key) {
|
|
||||||
$data = $this->readPartial($key);
|
|
||||||
|
|
||||||
if ($data === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data['files'] as $testFile => $sources) {
|
|
||||||
if (! isset($mergedFiles[$testFile])) {
|
|
||||||
$mergedFiles[$testFile] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
|
||||||
$mergedFiles[$testFile][$source] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data['tables'] as $testFile => $tables) {
|
|
||||||
if (! isset($mergedTables[$testFile])) {
|
|
||||||
$mergedTables[$testFile] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($tables as $table) {
|
|
||||||
$mergedTables[$testFile][$table] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data['inertia'] as $testFile => $components) {
|
|
||||||
if (! isset($mergedInertia[$testFile])) {
|
|
||||||
$mergedInertia[$testFile] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
|
||||||
$mergedInertia[$testFile][$component] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->state->delete($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finalised = [];
|
|
||||||
|
|
||||||
foreach ($mergedFiles as $testFile => $sourceSet) {
|
|
||||||
$finalised[$testFile] = array_keys($sourceSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finalisedTables = [];
|
|
||||||
|
|
||||||
foreach ($mergedTables as $testFile => $tableSet) {
|
|
||||||
$finalisedTables[$testFile] = array_keys($tableSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finalisedInertia = [];
|
|
||||||
|
|
||||||
foreach ($mergedInertia as $testFile => $componentSet) {
|
|
||||||
$finalisedInertia[$testFile] = array_keys($componentSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($finalised === []) {
|
if ($finalised === []) {
|
||||||
if ($this->replayRan) {
|
if ($this->replayRan) {
|
||||||
@@ -519,7 +562,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->snapshotTestResults();
|
$this->snapshotTestResults(markKnownTestFiles: true);
|
||||||
|
|
||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
@@ -534,10 +577,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
if (! Fingerprint::structuralMatches($stored, $current)) {
|
if (! Fingerprint::structuralMatches($stored, $current)) {
|
||||||
$drift = Fingerprint::structuralDrift($stored, $current);
|
$drift = Fingerprint::structuralDrift($stored, $current);
|
||||||
|
|
||||||
$this->renderBadge('INFO', sprintf(
|
$this->driftLabel = $this->formatStructuralDrift($drift);
|
||||||
'Graph structure outdated (%s).',
|
|
||||||
$this->formatStructuralDrift($drift),
|
|
||||||
));
|
|
||||||
|
|
||||||
if (in_array('composer_lock', $drift, true)) {
|
if (in_array('composer_lock', $drift, true)) {
|
||||||
$branchSha = $graph->recordedAtSha($this->branch);
|
$branchSha = $graph->recordedAtSha($this->branch);
|
||||||
@@ -547,7 +587,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$branchSha,
|
$branchSha,
|
||||||
);
|
);
|
||||||
if ($summary !== '') {
|
if ($summary !== '') {
|
||||||
$this->renderChild($summary);
|
$this->driftDetails = $summary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,7 +628,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
|
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
|
||||||
{
|
{
|
||||||
$this->watchPatterns->useDefaults($projectRoot);
|
$this->watchPatterns->useDefaults($projectRoot);
|
||||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
|
||||||
|
$subdirectoryPrefix = $this->gitSubdirectoryPrefix($projectRoot);
|
||||||
|
|
||||||
|
if ($subdirectoryPrefix !== null) {
|
||||||
|
Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
|
||||||
|
|
||||||
$fingerprint = Fingerprint::compute($projectRoot);
|
$fingerprint = Fingerprint::compute($projectRoot);
|
||||||
$this->startFingerprint = $fingerprint;
|
$this->startFingerprint = $fingerprint;
|
||||||
@@ -607,8 +654,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$changedFiles = new ChangedFiles($projectRoot);
|
$changedFiles = new ChangedFiles($projectRoot);
|
||||||
$branchSha = $graph->recordedAtSha($this->branch);
|
$branchSha = $graph->recordedAtSha($this->branch);
|
||||||
|
|
||||||
if ($changedFiles->gitAvailable()
|
if ($branchSha !== null
|
||||||
&& $branchSha !== null
|
|
||||||
&& $changedFiles->since($branchSha) === null) {
|
&& $changedFiles->since($branchSha) === null) {
|
||||||
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
|
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
|
||||||
$graph = null;
|
$graph = null;
|
||||||
@@ -618,6 +664,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
if (! $graph instanceof Graph
|
if (! $graph instanceof Graph
|
||||||
&& ! $forceRebuild
|
&& ! $forceRebuild
|
||||||
&& ! $this->baselineFetchAttemptedForDrift
|
&& ! $this->baselineFetchAttemptedForDrift
|
||||||
|
&& $this->watchPatterns->isBaselined()
|
||||||
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
||||||
$this->baselineFetchAttemptedForDrift = true;
|
$this->baselineFetchAttemptedForDrift = true;
|
||||||
$graph = $this->loadGraph($projectRoot);
|
$graph = $this->loadGraph($projectRoot);
|
||||||
@@ -631,6 +678,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
||||||
|
if ($graph instanceof Graph && $this->driftLabel === null) {
|
||||||
|
$this->freshGraphReason = 'recording coverage baseline';
|
||||||
|
}
|
||||||
|
|
||||||
return $this->enterRecordMode($arguments);
|
return $this->enterRecordMode($arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,7 +698,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
|
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
|
||||||
{
|
{
|
||||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
|
||||||
|
|
||||||
if ($replayingGlobal) {
|
if ($replayingGlobal) {
|
||||||
$this->installWorkerReplay($projectRoot);
|
$this->installWorkerReplay($projectRoot);
|
||||||
@@ -664,6 +715,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
|
$this->recorder->activateLinkTracking();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
@@ -731,6 +783,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private function activateWorkerRecorderForReplay(array $arguments): array
|
private function activateWorkerRecorderForReplay(array $arguments): array
|
||||||
{
|
{
|
||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
|
$this->recorder->activateLinkTracking();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
@@ -761,12 +814,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
{
|
{
|
||||||
$changedFiles = new ChangedFiles($projectRoot);
|
$changedFiles = new ChangedFiles($projectRoot);
|
||||||
|
|
||||||
if (! $changedFiles->gitAvailable()) {
|
|
||||||
$this->renderBadge('WARN', 'Git unavailable — running full suite.');
|
|
||||||
|
|
||||||
return $arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
$branchSha = $graph->recordedAtSha($this->branch);
|
$branchSha = $graph->recordedAtSha($this->branch);
|
||||||
$changed = $changedFiles->since($branchSha) ?? [];
|
$changed = $changedFiles->since($branchSha) ?? [];
|
||||||
|
|
||||||
@@ -787,18 +834,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
||||||
$failedFromCache = [];
|
$rerunFromCache = [];
|
||||||
|
|
||||||
|
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
|
||||||
|
$this->filteredMode = false;
|
||||||
|
|
||||||
|
$this->renderBadge('WARN', 'Some cached tests due a re-run could not be located on disk.');
|
||||||
|
$this->renderChild('Running the full suite with replay instead of a filtered run.');
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->filteredMode) {
|
if ($this->filteredMode) {
|
||||||
$failedFromCache = $graph->failedOrErroredTestFiles($this->branch);
|
$rerunFromCache = $graph->testFilesToRerun($this->branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
$affected = array_values(array_unique([
|
$affected = array_values(array_unique([
|
||||||
...$affectedFromChanges,
|
...$affectedFromChanges,
|
||||||
...$failedFromCache,
|
...$rerunFromCache,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$this->reportAffectedSummary($changed, $affectedFromChanges, $failedFromCache, $affected);
|
$this->reportAffectedSummary($changed, $affectedFromChanges, $rerunFromCache, $affected);
|
||||||
|
|
||||||
$affectedSet = array_fill_keys($affected, true);
|
$affectedSet = array_fill_keys($affected, true);
|
||||||
$canRefreshReplayEdges = $affected !== [] && $coverageAvailable;
|
$canRefreshReplayEdges = $affected !== [] && $coverageAvailable;
|
||||||
@@ -852,25 +906,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
/**
|
/**
|
||||||
* @param array<int, string> $changedFiles
|
* @param array<int, string> $changedFiles
|
||||||
* @param array<int, string> $affectedFromChanges
|
* @param array<int, string> $affectedFromChanges
|
||||||
* @param array<int, string> $failedFromCache
|
* @param array<int, string> $rerunFromCache
|
||||||
* @param array<int, string> $affected
|
* @param array<int, string> $affected
|
||||||
*/
|
*/
|
||||||
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void
|
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $rerunFromCache, array $affected): void
|
||||||
{
|
{
|
||||||
$this->output->writeln('');
|
$this->output->writeln('');
|
||||||
|
|
||||||
if ($affected === []) {
|
if ($affected === []) {
|
||||||
$this->renderChild('TIA mode enabled.');
|
$this->renderChild('Experimental TIA mode enabled.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$newFailures = $failedFromCache === []
|
$newReruns = $rerunFromCache === []
|
||||||
? 0
|
? 0
|
||||||
: count(array_diff($failedFromCache, $affectedFromChanges));
|
: count(array_diff($rerunFromCache, $affectedFromChanges));
|
||||||
|
|
||||||
$reasons = [];
|
$reasons = [];
|
||||||
$singleReason = (int) ($affectedFromChanges !== []) + (int) ($newFailures > 0) === 1;
|
$singleReason = (int) ($affectedFromChanges !== []) + (int) ($newReruns > 0) === 1;
|
||||||
|
|
||||||
if ($affectedFromChanges !== []) {
|
if ($affectedFromChanges !== []) {
|
||||||
$reasons[] = $singleReason
|
$reasons[] = $singleReason
|
||||||
@@ -887,22 +941,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($newFailures > 0) {
|
if ($newReruns > 0) {
|
||||||
$reasons[] = $singleReason
|
$reasons[] = $singleReason
|
||||||
? sprintf(
|
? sprintf(
|
||||||
'from %d previous failure%s',
|
'from %d previously unsuccessful test%s',
|
||||||
$newFailures,
|
$newReruns,
|
||||||
$newFailures === 1 ? '' : 's',
|
$newReruns === 1 ? '' : 's',
|
||||||
)
|
)
|
||||||
: sprintf(
|
: sprintf(
|
||||||
'%d from previous failure%s',
|
'%d from previously unsuccessful test%s',
|
||||||
$newFailures,
|
$newReruns,
|
||||||
$newFailures === 1 ? '' : 's',
|
$newReruns === 1 ? '' : 's',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->renderChild(sprintf(
|
$this->renderChild(sprintf(
|
||||||
'TIA mode enabled / %d affected test file%s%s.',
|
'Experimental TIA mode enabled / %d affected test file%s%s.',
|
||||||
count($affected),
|
count($affected),
|
||||||
count($affected) === 1 ? '' : 's',
|
count($affected) === 1 ? '' : 's',
|
||||||
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
|
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
|
||||||
@@ -963,16 +1017,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeln('');
|
||||||
$this->renderChild('TIA mode enabled / fresh graph.');
|
$this->renderFreshGraph();
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
|
$recorder->activateLinkTracking();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeln('');
|
||||||
$this->renderChild('TIA mode enabled / fresh graph.');
|
$this->renderFreshGraph();
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@@ -985,11 +1040,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function renderFreshGraph(): void
|
||||||
|
{
|
||||||
|
$headline = 'Experimental TIA mode enabled / fresh graph';
|
||||||
|
|
||||||
|
if ($this->driftLabel !== null) {
|
||||||
|
$headline .= sprintf(' (%s changed)', $this->driftLabel);
|
||||||
|
} elseif ($this->freshGraphReason !== null) {
|
||||||
|
$headline .= sprintf(' (%s)', $this->freshGraphReason);
|
||||||
|
} else {
|
||||||
|
$headline .= '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->renderChild($headline);
|
||||||
|
|
||||||
|
if ($this->driftDetails !== null) {
|
||||||
|
foreach (explode(', ', $this->driftDetails) as $detail) {
|
||||||
|
$this->output->writeln(sprintf(' <fg=gray>%s</>', $detail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function emitCoverageDriverMissing(): void
|
private function emitCoverageDriverMissing(): void
|
||||||
{
|
{
|
||||||
$this->renderBadge('WARN', 'No coverage driver is available — skipped.');
|
$this->output->writeln('');
|
||||||
$this->renderChild('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.');
|
|
||||||
$this->renderChild('Install or enable one and rerun with --tia.');
|
$this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1009,7 +1085,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->state->write($this->workerEdgesKey($this->workerToken()), $json);
|
$this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1071,7 +1147,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->state->write($this->workerResultsKey($this->workerToken()), $json);
|
$this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1157,6 +1233,45 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $partialKeys
|
||||||
|
* @return array{0: array<string, list<string>>, 1: array<string, list<string>>, 2: array<string, list<string>>}
|
||||||
|
*/
|
||||||
|
private function consumePartials(array $partialKeys): array
|
||||||
|
{
|
||||||
|
$merged = ['files' => [], 'tables' => [], 'inertia' => []];
|
||||||
|
|
||||||
|
foreach ($partialKeys as $key) {
|
||||||
|
$data = $this->readPartial($key);
|
||||||
|
|
||||||
|
if ($data === null) {
|
||||||
|
$this->state->delete($key);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['files', 'tables', 'inertia'] as $section) {
|
||||||
|
foreach ($data[$section] as $testFile => $values) {
|
||||||
|
if (! isset($merged[$section][$testFile])) {
|
||||||
|
$merged[$section][$testFile] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$merged[$section][$testFile][$value] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state->delete($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
array_map(array_keys(...), $merged['files']),
|
||||||
|
array_map(array_keys(...), $merged['tables']),
|
||||||
|
array_map(array_keys(...), $merged['inertia']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
|
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
|
||||||
*/
|
*/
|
||||||
@@ -1265,12 +1380,45 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$this->saveGraph($graph);
|
$this->saveGraph($graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of two per-test edge maps — piggybacked line-coverage edges plus
|
||||||
|
* the recorder's link-tracked edges (rendered Blade views, ...), which
|
||||||
|
* never appear in line coverage.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $coverage
|
||||||
|
* @param array<string, array<int, string>> $linked
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private function mergePerTestFiles(array $coverage, array $linked): array
|
||||||
|
{
|
||||||
|
foreach ($linked as $testFile => $sources) {
|
||||||
|
$existing = $coverage[$testFile] ?? [];
|
||||||
|
|
||||||
|
$coverage[$testFile] = array_values(array_unique([...$existing, ...$sources]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $coverage;
|
||||||
|
}
|
||||||
|
|
||||||
private function seedResultsInto(Graph $graph): void
|
private function seedResultsInto(Graph $graph): void
|
||||||
{
|
{
|
||||||
/** @var ResultCollector $collector */
|
/** @var ResultCollector $collector */
|
||||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||||
|
|
||||||
foreach ($collector->all() as $testId => $result) {
|
$results = $collector->all();
|
||||||
|
$touchedFiles = [];
|
||||||
|
|
||||||
|
foreach ($results as $testId => $result) {
|
||||||
|
$file = $result['file'] ?? null;
|
||||||
|
|
||||||
|
if ($file === null || str_contains($file, "eval()'d")) {
|
||||||
|
$file = $this->resolveFailedTestFile($testId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($file) && $file !== '') {
|
||||||
|
$touchedFiles[$file] = true;
|
||||||
|
}
|
||||||
|
|
||||||
$graph->setResult(
|
$graph->setResult(
|
||||||
$this->branch,
|
$this->branch,
|
||||||
$testId,
|
$testId,
|
||||||
@@ -1278,14 +1426,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$result['message'],
|
$result['message'],
|
||||||
$result['time'],
|
$result['time'],
|
||||||
$result['assertions'],
|
$result['assertions'],
|
||||||
$result['file'] ?? null,
|
$file,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||||
|
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||||
|
|
||||||
$collector->reset();
|
$collector->reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function snapshotTestResults(): void
|
private function snapshotTestResults(bool $markKnownTestFiles = false): void
|
||||||
{
|
{
|
||||||
/** @var ResultCollector $collector */
|
/** @var ResultCollector $collector */
|
||||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||||
@@ -1304,6 +1455,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$touchedFiles = [];
|
||||||
|
|
||||||
foreach ($results as $testId => $result) {
|
foreach ($results as $testId => $result) {
|
||||||
$file = $result['file'] ?? null;
|
$file = $result['file'] ?? null;
|
||||||
|
|
||||||
@@ -1311,6 +1464,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$file = $this->resolveFailedTestFile($testId);
|
$file = $this->resolveFailedTestFile($testId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_string($file) && $file !== '') {
|
||||||
|
$touchedFiles[$file] = true;
|
||||||
|
}
|
||||||
|
|
||||||
$graph->setResult(
|
$graph->setResult(
|
||||||
$this->branch,
|
$this->branch,
|
||||||
$testId,
|
$testId,
|
||||||
@@ -1322,6 +1479,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($markKnownTestFiles) {
|
||||||
|
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||||
|
|
||||||
$this->saveGraph($graph);
|
$this->saveGraph($graph);
|
||||||
$collector->reset();
|
$collector->reset();
|
||||||
}
|
}
|
||||||
@@ -1334,21 +1497,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reflection = new \ReflectionClass($class);
|
assert(property_exists($class, '__filename') && is_string($class::$__filename));
|
||||||
|
|
||||||
if ($reflection->hasProperty('__filename')) {
|
$filename = $class::$__filename;
|
||||||
try {
|
|
||||||
$filename = $reflection->getStaticPropertyValue('__filename');
|
|
||||||
} catch (\ReflectionException) {
|
|
||||||
$filename = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($filename) && $filename !== '' && ! str_contains($filename, "eval()'d")) {
|
if ($filename !== '' && ! str_contains($filename, "eval()'d")) {
|
||||||
return $filename;
|
return $filename;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$current = $reflection;
|
$current = new \ReflectionClass($class);
|
||||||
|
|
||||||
while ($current !== false) {
|
while ($current !== false) {
|
||||||
$file = $current->getFileName();
|
$file = $current->getFileName();
|
||||||
@@ -1365,14 +1522,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private function coverageReportActive(): bool
|
private function coverageReportActive(): bool
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
/** @var Coverage $coverage */
|
|
||||||
$coverage = Container::getInstance()->get(Coverage::class);
|
$coverage = Container::getInstance()->get(Coverage::class);
|
||||||
} catch (Throwable) {
|
assert($coverage instanceof Coverage);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $coverage->coverage === true;
|
return $coverage->coverage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1380,35 +1533,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function hasExplicitPathArgument(array $arguments): bool
|
private function hasExplicitPathArgument(array $arguments): bool
|
||||||
{
|
{
|
||||||
static $valueTakingFlags = [
|
|
||||||
'-c', '--configuration', '--bootstrap', '--cache-directory',
|
|
||||||
'--filter', '--group', '--exclude-group', '--covers', '--uses',
|
|
||||||
'--test-suffix', '--testsuite', '--exclude-testsuite',
|
|
||||||
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
|
|
||||||
'--include-path', '--whitelist',
|
|
||||||
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
|
||||||
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
|
|
||||||
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
|
|
||||||
'--coverage-filter', '--path-coverage',
|
|
||||||
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
|
||||||
'--compact', '--ci-build-id', '--min',
|
|
||||||
];
|
|
||||||
|
|
||||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
$testPaths = \Pest\Plugins\Tia\SourceScope::testPaths($projectRoot);
|
$testPaths = SourceScope::testPaths();
|
||||||
|
|
||||||
if ($testPaths === []) {
|
if ($testPaths === []) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($arguments as $index => $arg) {
|
foreach ($arguments as $index => $arg) {
|
||||||
if ($arg === '' || str_starts_with($arg, '-')) {
|
$arg = (string) $arg; // @phpstan-ignore-line
|
||||||
|
|
||||||
|
if ($arg === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_starts_with($arg, '-')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($index > 0) {
|
if ($index > 0) {
|
||||||
$previous = $arguments[$index - 1] ?? '';
|
$previous = $arguments[$index - 1] ?? '';
|
||||||
if (in_array($previous, $valueTakingFlags, true)) {
|
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1494,6 +1637,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
$this->baselineFetchAttemptedForDrift = true;
|
$this->baselineFetchAttemptedForDrift = true;
|
||||||
|
|
||||||
|
if (! $this->watchPatterns->isBaselined()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
|
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1505,7 +1652,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
|
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
|
||||||
$this->renderBadge('WARN', 'Fetched baseline still drifts — discarding.');
|
$this->output->writeln(' <fg=gray> However, baseline still drifts — discarding.</>');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1545,6 +1692,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return implode(', ', array_keys($seen));
|
return implode(', ', array_keys($seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The path from the git repository root down to $projectRoot (e.g.
|
||||||
|
* `laravel-app`) when the project is nested inside a larger repo, or `null`
|
||||||
|
* when the project root is itself the repo root (or git is unavailable).
|
||||||
|
* TIA requires the two to coincide: git reports and addresses paths
|
||||||
|
* relative to the repo root, while the dependency graph is project-relative.
|
||||||
|
*/
|
||||||
|
private function gitSubdirectoryPrefix(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = trim($process->getOutput());
|
||||||
|
|
||||||
|
return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
|
||||||
|
}
|
||||||
|
|
||||||
private function composerLockDelta(string $projectRoot, string $sha): string
|
private function composerLockDelta(string $projectRoot, string $sha): string
|
||||||
{
|
{
|
||||||
$current = @file_get_contents($projectRoot.'/composer.lock');
|
$current = @file_get_contents($projectRoot.'/composer.lock');
|
||||||
|
|||||||
+150
-242
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
|
||||||
use Pest\Exceptions\BaselineFetchFailed;
|
use Pest\Exceptions\BaselineFetchFailed;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
@@ -32,6 +31,29 @@ final readonly class BaselineSync
|
|||||||
|
|
||||||
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||||
|
|
||||||
|
private const array DIAGNOSES = [
|
||||||
|
'network' => [
|
||||||
|
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
|
||||||
|
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
||||||
|
],
|
||||||
|
'gh-auth' => [
|
||||||
|
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
|
||||||
|
'message' => 'authentication failed — run `gh auth login` and retry.',
|
||||||
|
],
|
||||||
|
'rate-limit' => [
|
||||||
|
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
|
||||||
|
'message' => 'GitHub API rate limit hit — try again later.',
|
||||||
|
],
|
||||||
|
'not-found' => [
|
||||||
|
'pattern' => '/404|not found|repository not found/i',
|
||||||
|
'message' => 'workflow or artifact not found in repo.',
|
||||||
|
],
|
||||||
|
'forbidden' => [
|
||||||
|
'pattern' => '/403|forbidden|access denied/i',
|
||||||
|
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private State $state,
|
private State $state,
|
||||||
private OutputInterface $output,
|
private OutputInterface $output,
|
||||||
@@ -64,13 +86,14 @@ final readonly class BaselineSync
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$failureKind = null;
|
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
||||||
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
|
$payload = $result['payload'];
|
||||||
|
$failureKind = $result['failureKind'];
|
||||||
|
|
||||||
if ($payload === null) {
|
if ($payload === null) {
|
||||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
if ($failureKind === 'no-runs' || $failureKind === null) {
|
||||||
$this->startCooldown();
|
$this->startCooldown();
|
||||||
$this->emitPublishInstructions($repo);
|
$this->emitPublishInstructions();
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -86,11 +109,6 @@ final readonly class BaselineSync
|
|||||||
|
|
||||||
$this->clearCooldown();
|
$this->clearCooldown();
|
||||||
|
|
||||||
$this->renderBadge('INFO', sprintf(
|
|
||||||
'Baseline ready (%s).',
|
|
||||||
$this->formatSize($payload['sizeOnDisk']),
|
|
||||||
));
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +156,7 @@ final readonly class BaselineSync
|
|||||||
return $seconds.'s';
|
return $seconds.'s';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function emitPublishInstructions(string $repo): void
|
private function emitPublishInstructions(): void
|
||||||
{
|
{
|
||||||
if ($this->isCi()) {
|
if ($this->isCi()) {
|
||||||
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
||||||
@@ -146,23 +164,8 @@ final readonly class BaselineSync
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$yaml = $this->isLaravel()
|
|
||||||
? $this->laravelWorkflowYaml()
|
|
||||||
: $this->genericWorkflowYaml();
|
|
||||||
|
|
||||||
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
||||||
$this->renderChild('To share the baseline with your team, add this workflow to the repo:');
|
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
|
||||||
$this->renderChild('.github/workflows/tia-baseline.yml');
|
|
||||||
|
|
||||||
$indentedYaml = array_map(
|
|
||||||
static fn (string $line): string => ' '.$line,
|
|
||||||
explode("\n", $yaml),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->output->writeln(['', ...$indentedYaml, '']);
|
|
||||||
|
|
||||||
$this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
|
|
||||||
$this->renderChild('Details: https://pestphp.com/docs/tia/ci');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isCi(): bool
|
private function isCi(): bool
|
||||||
@@ -172,79 +175,6 @@ final readonly class BaselineSync
|
|||||||
|| getenv('CIRCLECI') === 'true';
|
|| getenv('CIRCLECI') === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isLaravel(): bool
|
|
||||||
{
|
|
||||||
return class_exists(InstalledVersions::class)
|
|
||||||
&& InstalledVersions::isInstalled('laravel/framework');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function laravelWorkflowYaml(): string
|
|
||||||
{
|
|
||||||
return <<<'YAML'
|
|
||||||
name: TIA Baseline
|
|
||||||
on:
|
|
||||||
push: { branches: [main] }
|
|
||||||
schedule: [{ cron: '0 3 * * *' }]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
baseline:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with: { fetch-depth: 0 }
|
|
||||||
- uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
coverage: xdebug
|
|
||||||
extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl
|
|
||||||
- run: cp .env.example .env
|
|
||||||
- run: composer install --no-interaction --prefer-dist
|
|
||||||
- run: php artisan key:generate
|
|
||||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
|
||||||
- name: Stage baseline for upload
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p .pest-tia-baseline
|
|
||||||
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: pest-tia-baseline
|
|
||||||
path: .pest-tia-baseline/
|
|
||||||
retention-days: 30
|
|
||||||
YAML;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function genericWorkflowYaml(): string
|
|
||||||
{
|
|
||||||
return <<<'YAML'
|
|
||||||
name: TIA Baseline
|
|
||||||
on:
|
|
||||||
push: { branches: [main] }
|
|
||||||
schedule: [{ cron: '0 3 * * *' }]
|
|
||||||
workflow_dispatch:
|
|
||||||
jobs:
|
|
||||||
baseline:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with: { fetch-depth: 0 }
|
|
||||||
- uses: shivammathur/setup-php@v2
|
|
||||||
with: { php-version: '8.4', coverage: xdebug }
|
|
||||||
- run: composer install --no-interaction --prefer-dist
|
|
||||||
- run: ./vendor/bin/pest --parallel --tia --coverage
|
|
||||||
- name: Stage baseline for upload
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p .pest-tia-baseline
|
|
||||||
cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: pest-tia-baseline
|
|
||||||
path: .pest-tia-baseline/
|
|
||||||
retention-days: 30
|
|
||||||
YAML;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function detectGitHubRepo(string $projectRoot): ?string
|
private function detectGitHubRepo(string $projectRoot): ?string
|
||||||
{
|
{
|
||||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
||||||
@@ -281,14 +211,78 @@ YAML;
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param-out string|null $failureKind
|
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
|
||||||
*
|
|
||||||
* @return array{graph: string, coverage: ?string}|null
|
|
||||||
*/
|
*/
|
||||||
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
|
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
||||||
{
|
{
|
||||||
$failureKind = null;
|
$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')) {
|
if (! $this->commandExists('gh')) {
|
||||||
Panic::with(new BaselineFetchFailed(
|
Panic::with(new BaselineFetchFailed(
|
||||||
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||||
@@ -304,62 +298,24 @@ YAML;
|
|||||||
$hasAnchor,
|
$hasAnchor,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
|
||||||
|
|
||||||
if ($listError !== null) {
|
|
||||||
$failureKind = $listError['kind'];
|
|
||||||
|
|
||||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
|
||||||
Panic::with(new BaselineFetchFailed(
|
|
||||||
sprintf('Failed to query baseline runs — %s', $listError['message']),
|
|
||||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
|
||||||
$hasAnchor,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->renderBadge('WARN', sprintf(
|
|
||||||
'Failed to query baseline runs — %s',
|
|
||||||
$listError['message'],
|
|
||||||
));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($runId === null) {
|
|
||||||
$failureKind = 'no-runs';
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
|
||||||
|
|
||||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
|
||||||
@touch($runCacheDir);
|
|
||||||
|
|
||||||
$this->renderBadge('INFO', sprintf(
|
|
||||||
'Using cached baseline from %s (run %s).',
|
|
||||||
$repo,
|
|
||||||
$runId,
|
|
||||||
));
|
|
||||||
|
|
||||||
return $this->readArtifact($runCacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, failureKind: ?string}
|
||||||
|
*/
|
||||||
|
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
|
||||||
|
{
|
||||||
$artifactSize = $this->artifactSize($repo, $runId);
|
$artifactSize = $this->artifactSize($repo, $runId);
|
||||||
|
|
||||||
$this->renderBadge('INFO', $artifactSize !== null
|
$this->output->writeln('');
|
||||||
|
$this->renderChild($artifactSize !== null
|
||||||
? sprintf(
|
? sprintf(
|
||||||
'Fetching baseline (%s) from %s…',
|
'Downloading TIA baseline (%s) from %s…',
|
||||||
$this->formatSize($artifactSize),
|
$this->formatSize($artifactSize),
|
||||||
$repo,
|
$repo,
|
||||||
)
|
)
|
||||||
: sprintf(
|
: sprintf(
|
||||||
'Fetching baseline from %s…',
|
'Downloading TIA baseline from %s…',
|
||||||
$repo,
|
$repo,
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -373,37 +329,39 @@ YAML;
|
|||||||
$process->start();
|
$process->start();
|
||||||
|
|
||||||
$startedAt = microtime(true);
|
$startedAt = microtime(true);
|
||||||
|
$tick = 0;
|
||||||
|
|
||||||
while ($process->isRunning()) {
|
while ($process->isRunning()) {
|
||||||
$this->renderDownloadProgress($runCacheDir, $artifactSize, $startedAt);
|
$this->renderDownloadProgress($startedAt, $tick++);
|
||||||
usleep(250_000);
|
usleep(120_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
$process->wait();
|
$process->wait();
|
||||||
$this->clearProgressLine();
|
$this->clearProgressLine();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if ($process->isSuccessful()) {
|
||||||
|
return ['success' => true, 'failureKind' => null];
|
||||||
|
}
|
||||||
|
|
||||||
$this->cleanup($runCacheDir);
|
$this->cleanup($runCacheDir);
|
||||||
|
|
||||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||||
$failureKind = $diagnosis['kind'];
|
|
||||||
|
|
||||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
|
||||||
Panic::with(new BaselineFetchFailed(
|
|
||||||
sprintf('Baseline download failed — %s', $diagnosis['message']),
|
|
||||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
|
||||||
$hasAnchor,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->renderBadge('WARN', sprintf(
|
$this->renderBadge('WARN', sprintf(
|
||||||
'Baseline download failed — %s',
|
'Baseline download failed — %s',
|
||||||
$diagnosis['message'],
|
$diagnosis['message'],
|
||||||
));
|
));
|
||||||
|
|
||||||
return null;
|
return ['success' => false, 'failureKind' => $diagnosis['kind']];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
|
||||||
|
*/
|
||||||
|
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
|
||||||
|
{
|
||||||
$payload = $this->readArtifact($runCacheDir);
|
$payload = $this->readArtifact($runCacheDir);
|
||||||
|
|
||||||
if ($payload === null) {
|
if ($payload === null) {
|
||||||
@@ -416,8 +374,6 @@ YAML;
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->trimDownloadCache($projectRoot);
|
|
||||||
|
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,30 +399,18 @@ YAML;
|
|||||||
return is_numeric($size) ? (int) $size : null;
|
return is_numeric($size) ? (int) $size : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderDownloadProgress(string $dir, ?int $totalBytes, float $startedAt): void
|
private function renderDownloadProgress(float $startedAt, int $tick): void
|
||||||
{
|
{
|
||||||
$current = $this->dirSize($dir);
|
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
$elapsed = max(0.001, microtime(true) - $startedAt);
|
|
||||||
$speed = (int) ($current / $elapsed);
|
|
||||||
|
|
||||||
if ($totalBytes !== null && $totalBytes > 0) {
|
$elapsed = max(0.0, microtime(true) - $startedAt);
|
||||||
$percent = min(99, (int) floor(($current / $totalBytes) * 100));
|
$frame = $frames[$tick % count($frames)];
|
||||||
$message = sprintf(
|
|
||||||
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
|
|
||||||
$this->formatSize($current),
|
|
||||||
$this->formatSize($totalBytes),
|
|
||||||
$percent,
|
|
||||||
$this->formatSize($speed),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$message = sprintf(
|
|
||||||
' <fg=cyan>Downloading</> %s (%s/s)',
|
|
||||||
$this->formatSize($current),
|
|
||||||
$this->formatSize($speed),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->output->write("\r\033[K".$message);
|
$this->output->write(sprintf(
|
||||||
|
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
|
||||||
|
$frame,
|
||||||
|
$elapsed,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function clearProgressLine(): void
|
private function clearProgressLine(): void
|
||||||
@@ -548,12 +492,10 @@ YAML;
|
|||||||
$candidates = [];
|
$candidates = [];
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
foreach ($entries as $entry) {
|
||||||
if ($entry === '.') {
|
if (in_array($entry, ['.', '..'], true)) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($entry === '..') {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||||
|
|
||||||
if (! is_dir($path)) {
|
if (! is_dir($path)) {
|
||||||
@@ -624,59 +566,21 @@ YAML;
|
|||||||
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/(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', $output) === 1) {
|
foreach (self::DIAGNOSES as $kind => $diagnosis) {
|
||||||
return [
|
if (preg_match($diagnosis['pattern'], $output) === 1) {
|
||||||
'kind' => 'network',
|
return ['kind' => $kind, 'message' => $diagnosis['message']];
|
||||||
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
}
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/(authentication failed|not logged in|requires authentication|bad credentials|401)/i', $output) === 1) {
|
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
|
||||||
return [
|
|
||||||
'kind' => 'gh-auth',
|
|
||||||
'message' => 'authentication failed — run `gh auth login` and retry.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/(rate limit|too many requests|secondary rate limit)/i', $output) === 1) {
|
|
||||||
return [
|
|
||||||
'kind' => 'rate-limit',
|
|
||||||
'message' => 'GitHub API rate limit hit — try again later.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/(404|not found|repository not found)/i', $output) === 1) {
|
|
||||||
return [
|
|
||||||
'kind' => 'not-found',
|
|
||||||
'message' => 'workflow or artifact not found in repo.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/(403|forbidden|access denied)/i', $output) === 1) {
|
|
||||||
return [
|
|
||||||
'kind' => 'forbidden',
|
|
||||||
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = trim(strtok($output, "\n"));
|
|
||||||
|
|
||||||
return ['kind' => 'unknown', 'message' => $message];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function commandExists(string $cmd): bool
|
private function commandExists(string $cmd): bool
|
||||||
{
|
{
|
||||||
$probe = new Process(['command', '-v', $cmd]);
|
$process = new Process(['which', $cmd]);
|
||||||
$probe->run();
|
$process->run();
|
||||||
|
|
||||||
if ($probe->isSuccessful()) {
|
return $process->isSuccessful();
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$which = new Process(['which', $cmd]);
|
|
||||||
$which->run();
|
|
||||||
|
|
||||||
return $which->isSuccessful();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function cleanup(string $dir): void
|
private function cleanup(string $dir): void
|
||||||
@@ -685,13 +589,17 @@ YAML;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||||
|
);
|
||||||
|
|
||||||
if ($entries !== false) {
|
/** @var \SplFileInfo $entry */
|
||||||
foreach ($entries as $entry) {
|
foreach ($iterator as $entry) {
|
||||||
if (is_file($entry)) {
|
if ($entry->isDir()) {
|
||||||
@unlink($entry);
|
@rmdir($entry->getPathname());
|
||||||
}
|
} else {
|
||||||
|
@unlink($entry->getPathname());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,12 @@ final readonly class Bootstrapper implements BootstrapperContract
|
|||||||
public function __construct(private Container $container) {}
|
public function __construct(private Container $container) {}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
|
||||||
$this->container->add(State::class, new FileState($this->tempDir()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* across worktrees of the same repo. See {@see Storage} for the key
|
|
||||||
*/
|
|
||||||
private function tempDir(): string
|
|
||||||
{
|
{
|
||||||
$testSuite = $this->container->get(TestSuite::class);
|
$testSuite = $this->container->get(TestSuite::class);
|
||||||
assert($testSuite instanceof TestSuite);
|
assert($testSuite instanceof TestSuite);
|
||||||
|
|
||||||
return Storage::tempDir($testSuite->rootPath);
|
$tempDir = Storage::tempDir($testSuite->rootPath);
|
||||||
|
|
||||||
|
$this->container->add(State::class, new FileState($tempDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Exceptions\MissingDependency;
|
||||||
use Symfony\Component\Process\Process;
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,37 +35,27 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
foreach (array_keys($candidates) as $file) {
|
foreach (array_keys($candidates) as $file) {
|
||||||
$snapshot = $lastRunTree[$file] ?? null;
|
$snapshot = $lastRunTree[$file] ?? null;
|
||||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
$current = $this->currentHash($file);
|
||||||
$exists = is_file($absolute);
|
|
||||||
|
|
||||||
if ($snapshot === null) {
|
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
||||||
$remaining[] = $file;
|
$remaining[] = $file;
|
||||||
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $exists) {
|
return $remaining;
|
||||||
$remaining[] = $file;
|
}
|
||||||
|
|
||||||
continue;
|
private function currentHash(string $relativePath): ?string
|
||||||
|
{
|
||||||
|
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hash = ContentHash::of($absolute);
|
$hash = ContentHash::of($absolute);
|
||||||
|
|
||||||
if ($hash === false) {
|
return $hash === false ? null : $hash;
|
||||||
$remaining[] = $file;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hash === $snapshot) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining[] = $file;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $remaining;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,10 +90,6 @@ final readonly class ChangedFiles
|
|||||||
*/
|
*/
|
||||||
public function since(?string $sha): ?array
|
public function since(?string $sha): ?array
|
||||||
{
|
{
|
||||||
if (! $this->gitAvailable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|
||||||
if ($sha !== null && $sha !== '') {
|
if ($sha !== null && $sha !== '') {
|
||||||
@@ -121,13 +108,10 @@ final readonly class ChangedFiles
|
|||||||
if ($file === '') {
|
if ($file === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($this->shouldIgnore($file)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$unique[$file] = true;
|
$unique[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates = array_keys($unique);
|
$candidates = array_keys($this->filterIgnored($unique));
|
||||||
|
|
||||||
if ($sha !== null && $sha !== '') {
|
if ($sha !== null && $sha !== '') {
|
||||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||||
@@ -145,17 +129,9 @@ final readonly class ChangedFiles
|
|||||||
$remaining = [];
|
$remaining = [];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
$currentHash = $this->currentHash($file);
|
||||||
|
|
||||||
if (! is_file($absolute)) {
|
if ($currentHash === null) {
|
||||||
$remaining[] = $file;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentHash = ContentHash::of($absolute);
|
|
||||||
|
|
||||||
if ($currentHash === false) {
|
|
||||||
$remaining[] = $file;
|
$remaining[] = $file;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@@ -169,9 +145,7 @@ final readonly class ChangedFiles
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$baselineHash = ContentHash::ofContent($file, $baselineContent);
|
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
|
||||||
|
|
||||||
if ($currentHash !== $baselineHash) {
|
|
||||||
$remaining[] = $file;
|
$remaining[] = $file;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,37 +166,52 @@ final readonly class ChangedFiles
|
|||||||
return $process->getOutput();
|
return $process->getOutput();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shouldIgnore(string $path): bool
|
/**
|
||||||
|
* @param array<string, true> $candidates
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
private function filterIgnored(array $candidates): array
|
||||||
{
|
{
|
||||||
static $prefixes = [
|
if ($candidates === []) {
|
||||||
'.pest/',
|
return $candidates;
|
||||||
'.phpunit.cache/',
|
}
|
||||||
'.phpunit.result.cache',
|
|
||||||
'vendor/',
|
|
||||||
'node_modules/',
|
|
||||||
'bootstrap/cache/',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($prefixes as $prefix) {
|
$process = new Process(
|
||||||
if (str_starts_with($path, (string) $prefix)) {
|
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
|
||||||
return true;
|
$this->projectRoot,
|
||||||
|
);
|
||||||
|
$process->setTimeout(5.0);
|
||||||
|
$process->setInput(implode("\x00", array_keys($candidates)));
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$exitCode = $process->getExitCode();
|
||||||
|
|
||||||
|
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||||
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $process->getOutput();
|
||||||
|
|
||||||
|
if ($output === '') {
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
|
||||||
|
if ($ignored !== '') {
|
||||||
|
unset($candidates[$ignored]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return $candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function currentBranch(): ?string
|
public function currentBranch(): ?string
|
||||||
{
|
{
|
||||||
if (! $this->gitAvailable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
return null;
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$branch = trim($process->getOutput());
|
$branch = trim($process->getOutput());
|
||||||
@@ -230,14 +219,6 @@ final readonly class ChangedFiles
|
|||||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function gitAvailable(): bool
|
|
||||||
{
|
|
||||||
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
return $process->isSuccessful();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function shaIsReachable(string $sha): bool
|
private function shaIsReachable(string $sha): bool
|
||||||
{
|
{
|
||||||
$process = new Process(
|
$process = new Process(
|
||||||
@@ -261,7 +242,7 @@ final readonly class ChangedFiles
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
return [];
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->splitLines($process->getOutput());
|
return $this->splitLines($process->getOutput());
|
||||||
@@ -279,7 +260,7 @@ final readonly class ChangedFiles
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
return [];
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = $process->getOutput();
|
$output = $process->getOutput();
|
||||||
@@ -321,15 +302,11 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
public function currentSha(): ?string
|
public function currentSha(): ?string
|
||||||
{
|
{
|
||||||
if (! $this->gitAvailable()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
return null;
|
throw new MissingDependency('Tia mode', 'git');
|
||||||
}
|
}
|
||||||
|
|
||||||
$sha = trim($process->getOutput());
|
$sha = trim($process->getOutput());
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ final class Configuration
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function baselined(): self
|
||||||
|
{
|
||||||
|
/** @var WatchPatterns $watchPatterns */
|
||||||
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
$watchPatterns->markBaselined();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $patterns glob → project-relative test dir
|
* @param array<string, string> $patterns glob → project-relative test dir
|
||||||
* @return $this
|
* @return $this
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface Lockfile
|
||||||
|
{
|
||||||
|
public function applies(string $filename): bool;
|
||||||
|
|
||||||
|
public function fingerprint(string $contents): ?string;
|
||||||
|
}
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\Contracts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -12,7 +12,7 @@ interface WatchDefault
|
|||||||
public function applicable(): bool;
|
public function applicable(): bool;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, string>> glob → list of project-relative test dirs
|
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
|
||||||
*/
|
*/
|
||||||
public function defaults(string $projectRoot, string $testPath): array;
|
public function defaults(string $projectRoot, string $testPath): array;
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
|
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
|
||||||
use ReflectionClass;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,22 +103,8 @@ final class CoverageCollector
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reflection = new ReflectionClass($className);
|
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||||
|
|
||||||
if ($reflection->hasProperty('__filename')) {
|
return $className::$__filename;
|
||||||
$property = $reflection->getProperty('__filename');
|
|
||||||
|
|
||||||
if ($property->isStatic()) {
|
|
||||||
$value = $property->getValue();
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $reflection->getFileName();
|
|
||||||
|
|
||||||
return is_string($file) ? $file : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use Pest\Plugins\Tia;
|
|||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||||
|
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||||
|
use SebastianBergmann\CodeCoverage\Filter;
|
||||||
|
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +22,7 @@ final class CoverageMerger
|
|||||||
{
|
{
|
||||||
$state = self::state();
|
$state = self::state();
|
||||||
|
|
||||||
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ final class CoverageMerger
|
|||||||
$current = self::requireCoverage($reportPath);
|
$current = self::requireCoverage($reportPath);
|
||||||
|
|
||||||
if ($current instanceof CodeCoverage) {
|
if ($current instanceof CodeCoverage) {
|
||||||
|
self::primeUncoveredFiles($current);
|
||||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +56,9 @@ final class CoverageMerger
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::primeUncoveredFiles($cached);
|
||||||
|
self::primeUncoveredFiles($current);
|
||||||
|
|
||||||
self::stripCurrentTestsFromCached($cached, $current);
|
self::stripCurrentTestsFromCached($cached, $current);
|
||||||
|
|
||||||
$cached->merge($current);
|
$cached->merge($current);
|
||||||
@@ -65,6 +72,11 @@ final class CoverageMerger
|
|||||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
|
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function primeUncoveredFiles(CodeCoverage $coverage): void
|
||||||
|
{
|
||||||
|
$coverage->getData(false);
|
||||||
|
}
|
||||||
|
|
||||||
private static function compress(string $bytes): string
|
private static function compress(string $bytes): string
|
||||||
{
|
{
|
||||||
$compressed = @gzencode($bytes);
|
$compressed = @gzencode($bytes);
|
||||||
@@ -131,15 +143,12 @@ final class CoverageMerger
|
|||||||
return array_keys($ids);
|
return array_keys($ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function state(): ?State
|
private static function state(): State
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
$state = Container::getInstance()->get(State::class);
|
$state = Container::getInstance()->get(State::class);
|
||||||
} catch (Throwable) {
|
assert($state instanceof State);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $state instanceof State ? $state : null;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
||||||
@@ -155,7 +164,60 @@ final class CoverageMerger
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $value instanceof CodeCoverage ? $value : null;
|
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
|
||||||
|
if ($value instanceof CodeCoverage) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
|
||||||
|
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
|
||||||
|
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
|
||||||
|
// be rebuilt into one before it can be merged.
|
||||||
|
return self::coverageFromSerializedData($reportPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
|
||||||
|
{
|
||||||
|
if ($reportPath === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$serialized = new Unserializer()->unserialize($reportPath);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $serialized['codeCoverage'];
|
||||||
|
$basePath = $serialized['basePath'];
|
||||||
|
|
||||||
|
if ($basePath !== '') {
|
||||||
|
foreach ($data->coveredFiles() as $relativePath) {
|
||||||
|
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage = self::emptyCoverage();
|
||||||
|
|
||||||
|
if (! $coverage instanceof CodeCoverage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage->setData($data);
|
||||||
|
$coverage->setTests($serialized['testResults']);
|
||||||
|
|
||||||
|
return $coverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function emptyCoverage(): ?CodeCoverage
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$filter = new Filter;
|
||||||
|
|
||||||
|
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Edges;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class AutoloadEdges
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, true>
|
|
||||||
*/
|
|
||||||
public static function snapshot(): array
|
|
||||||
{
|
|
||||||
$files = [];
|
|
||||||
|
|
||||||
foreach (get_included_files() as $file) {
|
|
||||||
if ($file !== '') {
|
|
||||||
$files[$file] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, true> $before
|
|
||||||
* @param array<string, true> $after
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
|
|
||||||
{
|
|
||||||
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
|
||||||
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach (array_keys($after) as $file) {
|
|
||||||
if (isset($before[$file])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$real = @realpath($file);
|
|
||||||
if ($real === false) {
|
|
||||||
$real = $file;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($testReal !== false && $real === $testReal) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! str_starts_with($real, $root)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
|
||||||
|
|
||||||
if (self::ignored($relative)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! str_ends_with($relative, '.php')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[$real] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys($out);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function ignored(string $relative): bool
|
|
||||||
{
|
|
||||||
static $prefixes = [
|
|
||||||
'vendor/',
|
|
||||||
'node_modules/',
|
|
||||||
'storage/framework/',
|
|
||||||
'bootstrap/cache/',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($prefixes as $prefix) {
|
|
||||||
if (str_starts_with($relative, (string) $prefix)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,11 +34,7 @@ final class BladeEdges
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($app->bound(self::MARKER)) {
|
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $app->bound('view')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,7 @@ final class InertiaEdges
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($app->bound(self::MARKER)) {
|
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $app->bound('events')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,18 +50,11 @@ final class InertiaEdges
|
|||||||
}
|
}
|
||||||
|
|
||||||
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
||||||
if (! property_exists($event, 'response')) {
|
if (! property_exists($event, 'response') || ! is_object($event->response)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var mixed $response */
|
$component = self::extractComponent($event->response);
|
||||||
$response = $event->response;
|
|
||||||
|
|
||||||
if (! is_object($response)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$component = self::extractComponent($response);
|
|
||||||
|
|
||||||
if ($component !== null) {
|
if ($component !== null) {
|
||||||
$recorder->linkInertiaComponent($component);
|
$recorder->linkInertiaComponent($component);
|
||||||
@@ -75,32 +64,16 @@ final class InertiaEdges
|
|||||||
|
|
||||||
private static function extractComponent(object $response): ?string
|
private static function extractComponent(object $response): ?string
|
||||||
{
|
{
|
||||||
if (property_exists($response, 'headers') && is_object($response->headers)) {
|
|
||||||
$headers = $response->headers;
|
|
||||||
|
|
||||||
if (method_exists($headers, 'has') && $headers->has('X-Inertia')) {
|
|
||||||
$content = self::readContent($response);
|
|
||||||
|
|
||||||
if ($content !== null) {
|
|
||||||
/** @var mixed $decoded */
|
|
||||||
$decoded = json_decode($content, true);
|
|
||||||
|
|
||||||
if (is_array($decoded)
|
|
||||||
&& isset($decoded['component'])
|
|
||||||
&& is_string($decoded['component'])
|
|
||||||
&& $decoded['component'] !== '') {
|
|
||||||
return $decoded['component'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = self::readContent($response);
|
$content = self::readContent($response);
|
||||||
|
|
||||||
if ($content === null) {
|
if ($content === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self::isInertiaJsonResponse($response)) {
|
||||||
|
return self::componentFromJson($content);
|
||||||
|
}
|
||||||
|
|
||||||
if (str_contains($content, 'type="application/json"')
|
if (str_contains($content, 'type="application/json"')
|
||||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
||||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||||
@@ -112,16 +85,23 @@ final class InertiaEdges
|
|||||||
|
|
||||||
if (str_contains($content, 'data-page=')
|
if (str_contains($content, 'data-page=')
|
||||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
return self::componentFromJson(html_entity_decode($match[1]));
|
||||||
|
|
||||||
if ($component !== null) {
|
|
||||||
return $component;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function isInertiaJsonResponse(object $response): bool
|
||||||
|
{
|
||||||
|
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = $response->headers;
|
||||||
|
|
||||||
|
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
|
||||||
|
}
|
||||||
|
|
||||||
private static function componentFromJson(string $json): ?string
|
private static function componentFromJson(string $json): ?string
|
||||||
{
|
{
|
||||||
/** @var mixed $decoded */
|
/** @var mixed $decoded */
|
||||||
|
|||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia\Enums;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
enum Replay
|
enum ReplayType
|
||||||
{
|
{
|
||||||
case No;
|
case None;
|
||||||
case Pass;
|
case Pass;
|
||||||
|
case Risky;
|
||||||
case Skipped;
|
case Skipped;
|
||||||
case Incomplete;
|
case Incomplete;
|
||||||
case Failure;
|
case Failure;
|
||||||
@@ -20,11 +21,12 @@ enum Replay
|
|||||||
public static function fromStatus(?TestStatus $status): self
|
public static function fromStatus(?TestStatus $status): self
|
||||||
{
|
{
|
||||||
if (! $status instanceof TestStatus) {
|
if (! $status instanceof TestStatus) {
|
||||||
return self::No;
|
return self::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$status->isSuccess(), $status->isRisky() => self::Pass,
|
$status->isSuccess() => self::Pass,
|
||||||
|
$status->isRisky() => self::Risky,
|
||||||
$status->isSkipped() => self::Skipped,
|
$status->isSkipped() => self::Skipped,
|
||||||
$status->isIncomplete() => self::Incomplete,
|
$status->isIncomplete() => self::Incomplete,
|
||||||
default => self::Failure,
|
default => self::Failure,
|
||||||
@@ -9,9 +9,11 @@ use Pest\Plugins\Tia\Contracts\State;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class FileState implements State
|
final class FileState implements State
|
||||||
{
|
{
|
||||||
private string $rootDir;
|
private readonly string $rootDir;
|
||||||
|
|
||||||
|
private ?string $resolvedRoot = null;
|
||||||
|
|
||||||
public function __construct(string $rootDir)
|
public function __construct(string $rootDir)
|
||||||
{
|
{
|
||||||
@@ -87,6 +89,10 @@ final readonly class FileState implements State
|
|||||||
$keys = [];
|
$keys = [];
|
||||||
|
|
||||||
foreach ($matches as $path) {
|
foreach ($matches as $path) {
|
||||||
|
if (str_ends_with($path, '.tmp')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$keys[] = basename($path);
|
$keys[] = basename($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,9 +106,17 @@ final readonly class FileState implements State
|
|||||||
|
|
||||||
private function resolvedRoot(): ?string
|
private function resolvedRoot(): ?string
|
||||||
{
|
{
|
||||||
|
if ($this->resolvedRoot !== null) {
|
||||||
|
return $this->resolvedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
$resolved = @realpath($this->rootDir);
|
$resolved = @realpath($this->rootDir);
|
||||||
|
|
||||||
return $resolved === false ? null : $resolved;
|
if ($resolved === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolvedRoot = $resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensureRoot(): bool
|
private function ensureRoot(): bool
|
||||||
|
|||||||
+113
-117
@@ -4,17 +4,27 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||||
|
use Symfony\Component\Finder\Finder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Fingerprint
|
final readonly class Fingerprint
|
||||||
{
|
{
|
||||||
private const int SCHEMA_VERSION = 14;
|
private const int SCHEMA_VERSION = 18;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<int, class-string<Lockfile>>
|
||||||
|
*/
|
||||||
|
private const array LOCKFILES = [
|
||||||
|
Lockfiles\PackageLock::class,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* structural: array<string, int|string|null>,
|
* structural: array<string, int|string|null>,
|
||||||
* environmental: array<string, string|null>,
|
* environmental: array<string, int|string|null>,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public static function compute(string $projectRoot): array
|
public static function compute(string $projectRoot): array
|
||||||
@@ -22,17 +32,22 @@ final readonly class Fingerprint
|
|||||||
return [
|
return [
|
||||||
'structural' => [
|
'structural' => [
|
||||||
'schema' => self::SCHEMA_VERSION,
|
'schema' => self::SCHEMA_VERSION,
|
||||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
'composer_lock' => self::composerLockHash($projectRoot),
|
||||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
|
||||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
|
||||||
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||||
|
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||||
'vite_config' => self::viteConfigHash($projectRoot),
|
'vite_config' => self::viteConfigHash($projectRoot),
|
||||||
'package_json' => self::packageJsonHash($projectRoot),
|
// 'package_json' => self::packageJsonHash($projectRoot),
|
||||||
'package_lock' => self::packageLockHash($projectRoot),
|
'package_lock' => self::packageLockHash($projectRoot),
|
||||||
'js_config' => self::jsConfigHash($projectRoot),
|
'js_config' => self::jsConfigHash($projectRoot),
|
||||||
'composer_json' => self::composerJsonHash($projectRoot),
|
// 'composer_json' => self::composerJsonHash($projectRoot),
|
||||||
],
|
],
|
||||||
'environmental' => [
|
'environmental' => [
|
||||||
|
'php_minor' => PHP_MAJOR_VERSION,
|
||||||
|
|
||||||
|
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
||||||
|
// 'env_files' => self::envFilesHash($projectRoot),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -59,30 +74,11 @@ final readonly class Fingerprint
|
|||||||
*/
|
*/
|
||||||
public static function structuralDrift(array $stored, array $current): array
|
public static function structuralDrift(array $stored, array $current): array
|
||||||
{
|
{
|
||||||
$a = self::structuralOnly($stored);
|
return self::detectDrift(
|
||||||
$b = self::structuralOnly($current);
|
self::structuralOnly($stored),
|
||||||
|
self::structuralOnly($current),
|
||||||
$drifts = [];
|
'schema',
|
||||||
|
);
|
||||||
foreach ($a as $key => $value) {
|
|
||||||
if ($key === 'schema') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (($b[$key] ?? null) !== $value) {
|
|
||||||
$drifts[] = $key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($b as $key => $value) {
|
|
||||||
if ($key === 'schema') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! array_key_exists($key, $a) && $value !== null) {
|
|
||||||
$drifts[] = $key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($drifts));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,18 +88,34 @@ final readonly class Fingerprint
|
|||||||
*/
|
*/
|
||||||
public static function environmentalDrift(array $stored, array $current): array
|
public static function environmentalDrift(array $stored, array $current): array
|
||||||
{
|
{
|
||||||
$a = self::environmentalOnly($stored);
|
return self::detectDrift(
|
||||||
$b = self::environmentalOnly($current);
|
self::environmentalOnly($stored),
|
||||||
|
self::environmentalOnly($current),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $a
|
||||||
|
* @param array<string, mixed> $b
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
|
||||||
|
{
|
||||||
$drifts = [];
|
$drifts = [];
|
||||||
|
|
||||||
foreach ($a as $key => $value) {
|
foreach ($a as $key => $value) {
|
||||||
|
if ($key === $skipKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (($b[$key] ?? null) !== $value) {
|
if (($b[$key] ?? null) !== $value) {
|
||||||
$drifts[] = $key;
|
$drifts[] = $key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($b as $key => $value) {
|
foreach ($b as $key => $value) {
|
||||||
|
if ($key === $skipKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (! array_key_exists($key, $a) && $value !== null) {
|
if (! array_key_exists($key, $a) && $value !== null) {
|
||||||
$drifts[] = $key;
|
$drifts[] = $key;
|
||||||
}
|
}
|
||||||
@@ -157,7 +169,11 @@ final readonly class Fingerprint
|
|||||||
{
|
{
|
||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
|
||||||
|
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
||||||
|
|
||||||
if ($hash !== null) {
|
if ($hash !== null) {
|
||||||
@@ -173,6 +189,10 @@ final readonly class Fingerprint
|
|||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
||||||
|
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
||||||
|
|
||||||
if ($hash !== null) {
|
if ($hash !== null) {
|
||||||
@@ -183,47 +203,9 @@ final readonly class Fingerprint
|
|||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function packageJsonHash(string $projectRoot): ?string
|
private static function composerLockHash(string $projectRoot): ?string
|
||||||
{
|
{
|
||||||
$path = $projectRoot.'/package.json';
|
return self::trackedHash($projectRoot, 'composer.lock');
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
|
||||||
|
|
||||||
if (! is_array($data)) {
|
|
||||||
$hash = @hash_file('xxh128', $path);
|
|
||||||
|
|
||||||
return $hash === false ? null : $hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
$relevant = [
|
|
||||||
'type' => $data['type'] ?? null,
|
|
||||||
'packageManager' => $data['packageManager'] ?? null,
|
|
||||||
'dependencies' => $data['dependencies'] ?? null,
|
|
||||||
'devDependencies' => $data['devDependencies'] ?? null,
|
|
||||||
'optionalDependencies' => $data['optionalDependencies'] ?? null,
|
|
||||||
'peerDependencies' => $data['peerDependencies'] ?? null,
|
|
||||||
'overrides' => $data['overrides'] ?? null,
|
|
||||||
'resolutions' => $data['resolutions'] ?? null,
|
|
||||||
'imports' => $data['imports'] ?? null,
|
|
||||||
'exports' => $data['exports'] ?? null,
|
|
||||||
'browser' => $data['browser'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
self::sortRecursively($relevant);
|
|
||||||
|
|
||||||
$json = json_encode($relevant);
|
|
||||||
|
|
||||||
return $json === false ? null : hash('xxh128', $json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function packageLockHash(string $projectRoot): ?string
|
private static function packageLockHash(string $projectRoot): ?string
|
||||||
@@ -231,7 +213,11 @@ final readonly class Fingerprint
|
|||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||||
|
|
||||||
if ($hash !== null) {
|
if ($hash !== null) {
|
||||||
$parts[] = $name.':'.$hash;
|
$parts[] = $name.':'.$hash;
|
||||||
@@ -241,68 +227,78 @@ final readonly class Fingerprint
|
|||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function composerJsonHash(string $projectRoot): ?string
|
private static function lockfileHash(string $path, string $name): ?string
|
||||||
{
|
{
|
||||||
$path = $projectRoot.'/composer.json';
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = @file_get_contents($path);
|
$contents = @file_get_contents($path);
|
||||||
|
|
||||||
if ($raw === false) {
|
if ($contents === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
foreach (self::LOCKFILES as $class) {
|
||||||
|
$handler = new $class;
|
||||||
|
|
||||||
if (! is_array($data)) {
|
if (! $handler->applies($name)) {
|
||||||
$hash = @hash_file('xxh128', $path);
|
continue;
|
||||||
|
|
||||||
return $hash === false ? null : $hash;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
$fingerprint = $handler->fingerprint($contents);
|
||||||
$relevantConfig = array_intersect_key($config, [
|
|
||||||
'platform' => true,
|
|
||||||
'allow-plugins' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$relevant = [
|
if ($fingerprint !== null) {
|
||||||
'autoload' => $data['autoload'] ?? null,
|
return $fingerprint;
|
||||||
'autoload-dev' => $data['autoload-dev'] ?? null,
|
|
||||||
'require' => $data['require'] ?? null,
|
|
||||||
'require-dev' => $data['require-dev'] ?? null,
|
|
||||||
'extra' => $data['extra'] ?? null,
|
|
||||||
'repositories' => $data['repositories'] ?? null,
|
|
||||||
'minimum-stability' => $data['minimum-stability'] ?? null,
|
|
||||||
'prefer-stable' => $data['prefer-stable'] ?? null,
|
|
||||||
'config' => $relevantConfig === [] ? null : $relevantConfig,
|
|
||||||
];
|
|
||||||
|
|
||||||
self::sortRecursively($relevant);
|
|
||||||
|
|
||||||
$json = json_encode($relevant);
|
|
||||||
|
|
||||||
return $json === false ? null : hash('xxh128', $json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function sortRecursively(mixed &$value): void
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('xxh128', $contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
||||||
{
|
{
|
||||||
if (! is_array($value)) {
|
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$isAssoc = ! array_is_list($value);
|
return self::hashIfExists($projectRoot.'/'.$relativePath);
|
||||||
|
|
||||||
if ($isAssoc) {
|
|
||||||
ksort($value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($value as &$child) {
|
/**
|
||||||
self::sortRecursively($child);
|
* Returns true when the file exists and is not gitignored.
|
||||||
|
*
|
||||||
|
* Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
|
||||||
|
* regenerate per-machine with OS-specific optional deps, which would
|
||||||
|
* otherwise force a fingerprint mismatch on every fetched baseline.
|
||||||
|
*/
|
||||||
|
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
|
||||||
|
{
|
||||||
|
if (! is_file($projectRoot.'/'.$relativePath)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static $cache = [];
|
||||||
|
|
||||||
|
$key = $projectRoot."\0".$relativePath;
|
||||||
|
|
||||||
|
if (isset($cache[$key])) {
|
||||||
|
return $cache[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
|
||||||
|
return $cache[$key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$finder = (new Finder)
|
||||||
|
->in($projectRoot)
|
||||||
|
->depth('== 0')
|
||||||
|
->name($relativePath)
|
||||||
|
->ignoreVCSIgnored(true);
|
||||||
|
|
||||||
|
return $cache[$key] = $finder->hasResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function contentHashOrNull(string $path): ?string
|
private static function contentHashOrNull(string $path): ?string
|
||||||
|
|||||||
+407
-161
@@ -5,11 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Factories\TestCaseFactory;
|
use Pest\Factories\TestCaseFactory;
|
||||||
|
use Pest\Factories\TestCaseMethodFactory;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use Pest\Support\View;
|
use Pest\Support\View;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\Attributes\Group;
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
use PHPUnit\TextUI\Configuration\Registry;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -51,6 +53,9 @@ final class Graph
|
|||||||
/** @var array<string, true>|null */
|
/** @var array<string, true>|null */
|
||||||
private ?array $archTestFiles = null;
|
private ?array $archTestFiles = null;
|
||||||
|
|
||||||
|
/** @var array<string, string|false> */
|
||||||
|
private array $realpathCache = [];
|
||||||
|
|
||||||
public function __construct(string $projectRoot)
|
public function __construct(string $projectRoot)
|
||||||
{
|
{
|
||||||
$real = @realpath($projectRoot);
|
$real = @realpath($projectRoot);
|
||||||
@@ -82,37 +87,83 @@ final class Graph
|
|||||||
*/
|
*/
|
||||||
public function affected(array $changedFiles): array
|
public function affected(array $changedFiles): array
|
||||||
{
|
{
|
||||||
$normalised = [];
|
[$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles);
|
||||||
|
|
||||||
|
$affectedSet = [];
|
||||||
|
|
||||||
|
$unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet);
|
||||||
|
|
||||||
|
[$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]
|
||||||
|
= $this->applyInertiaChanges($nonMigrationPaths, $affectedSet);
|
||||||
|
|
||||||
|
$unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet);
|
||||||
|
|
||||||
|
$this->applyTestFileChanges($nonMigrationPaths, $affectedSet);
|
||||||
|
|
||||||
|
$staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet);
|
||||||
|
|
||||||
|
$this->applyWatchPatternFallback(
|
||||||
|
$nonMigrationPaths,
|
||||||
|
$unparseableMigrations,
|
||||||
|
$preciselyHandledPages,
|
||||||
|
$sharedFilesResolved,
|
||||||
|
$staticallyHandledBlade,
|
||||||
|
$affectedSet,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet);
|
||||||
|
|
||||||
|
return array_keys($affectedSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $changedFiles
|
||||||
|
* @return array{0: list<string>, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function partitionChangedPaths(array $changedFiles): array
|
||||||
|
{
|
||||||
|
$migrations = [];
|
||||||
|
$nonMigrations = [];
|
||||||
|
|
||||||
foreach ($changedFiles as $file) {
|
foreach ($changedFiles as $file) {
|
||||||
$rel = $this->relative($file);
|
$rel = $this->relative($file);
|
||||||
|
|
||||||
if ($rel !== null) {
|
if ($rel === null) {
|
||||||
$normalised[] = $rel;
|
continue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$affectedSet = [];
|
|
||||||
|
|
||||||
$migrationPaths = [];
|
|
||||||
$nonMigrationPaths = [];
|
|
||||||
|
|
||||||
foreach ($normalised as $rel) {
|
|
||||||
if ($this->isMigrationPath($rel)) {
|
if ($this->isMigrationPath($rel)) {
|
||||||
$migrationPaths[] = $rel;
|
$migrations[] = $rel;
|
||||||
} else {
|
} else {
|
||||||
$nonMigrationPaths[] = $rel;
|
$nonMigrations[] = $rel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [$migrations, $nonMigrations];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $migrationPaths
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
* @return list<string> Unparseable migrations (caller treats as unknown-to-graph).
|
||||||
|
*/
|
||||||
|
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||||
|
{
|
||||||
|
// With no recorded table usage at all, table intersection can never
|
||||||
|
// select anything — route every migration change through the
|
||||||
|
// watch-pattern fallback instead of silently skipping tests.
|
||||||
|
if ($this->testTables === []) {
|
||||||
|
return $migrationPaths;
|
||||||
|
}
|
||||||
|
|
||||||
$changedTables = [];
|
$changedTables = [];
|
||||||
$unparseableMigrations = [];
|
$unparseable = [];
|
||||||
|
|
||||||
foreach ($migrationPaths as $rel) {
|
foreach ($migrationPaths as $rel) {
|
||||||
$tables = $this->tablesForMigration($rel);
|
$tables = $this->tablesForMigration($rel);
|
||||||
|
|
||||||
if ($tables === []) {
|
if ($tables === []) {
|
||||||
$unparseableMigrations[] = $rel;
|
$unparseable[] = $rel;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -138,6 +189,17 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $unparseable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $nonMigrationPaths
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
|
||||||
|
* globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved
|
||||||
|
*/
|
||||||
|
private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||||
|
{
|
||||||
$globalFrontendRuntimeFiles = [];
|
$globalFrontendRuntimeFiles = [];
|
||||||
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
@@ -169,6 +231,7 @@ final class Graph
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sharedFilesResolved = [];
|
$sharedFilesResolved = [];
|
||||||
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -176,12 +239,12 @@ final class Graph
|
|||||||
if (isset($preciselyHandledPages[$rel])) {
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! isset($this->jsFileToComponents[$rel])) {
|
if (! isset($this->jsFileToComponents[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$touchedAny = false;
|
$touchedAny = false;
|
||||||
|
|
||||||
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
|
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
|
||||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||||
$changedComponents[$pageComponent] = true;
|
$changedComponents[$pageComponent] = true;
|
||||||
@@ -195,6 +258,7 @@ final class Graph
|
|||||||
}
|
}
|
||||||
|
|
||||||
$newJsFiles = [];
|
$newJsFiles = [];
|
||||||
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -215,39 +279,7 @@ final class Graph
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($newJsFiles !== []) {
|
if ($newJsFiles !== []) {
|
||||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
$this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
|
||||||
|
|
||||||
if ($freshMap === null) {
|
|
||||||
View::render('components.badge', [
|
|
||||||
'type' => 'WARN',
|
|
||||||
'content' => sprintf(
|
|
||||||
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
|
||||||
count($newJsFiles),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
foreach ($newJsFiles as $rel) {
|
|
||||||
$pages = $freshMap[$rel] ?? [];
|
|
||||||
|
|
||||||
if ($pages === []) {
|
|
||||||
$sharedFilesResolved[$rel] = true;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$touchedAny = false;
|
|
||||||
foreach ($pages as $pageComponent) {
|
|
||||||
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
|
||||||
$changedComponents[$pageComponent] = true;
|
|
||||||
$touchedAny = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($touchedAny) {
|
|
||||||
$sharedFilesResolved[$rel] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($changedComponents !== []) {
|
if ($changedComponents !== []) {
|
||||||
@@ -266,6 +298,61 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $newJsFiles
|
||||||
|
* @param array<string, true> $changedComponents
|
||||||
|
* @param array<string, true> $sharedFilesResolved
|
||||||
|
*/
|
||||||
|
private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void
|
||||||
|
{
|
||||||
|
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||||
|
|
||||||
|
if ($freshMap === null) {
|
||||||
|
View::render('components.badge', [
|
||||||
|
'type' => 'WARN',
|
||||||
|
'content' => sprintf(
|
||||||
|
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||||
|
count($newJsFiles),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($newJsFiles as $rel) {
|
||||||
|
$pages = $freshMap[$rel] ?? [];
|
||||||
|
|
||||||
|
if ($pages === []) {
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touchedAny = false;
|
||||||
|
|
||||||
|
foreach ($pages as $pageComponent) {
|
||||||
|
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||||
|
$changedComponents[$pageComponent] = true;
|
||||||
|
$touchedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($touchedAny) {
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $nonMigrationPaths
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
* @return array<string, true> Unknown source dirs (sibling-heuristic).
|
||||||
|
*/
|
||||||
|
private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||||
|
{
|
||||||
$changedIds = [];
|
$changedIds = [];
|
||||||
$unknownSourceDirs = [];
|
$unknownSourceDirs = [];
|
||||||
$sourcePhpChanged = false;
|
$sourcePhpChanged = false;
|
||||||
@@ -282,9 +369,7 @@ final class Graph
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
||||||
$absolute = $this->projectRoot.'/'.$rel;
|
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||||
|
|
||||||
if (! is_file($absolute)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +401,18 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A changed file inside the configured test suites is itself the unit
|
return $unknownSourceDirs;
|
||||||
// of work — always run it (new untracked tests, edited tests, renames).
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A changed file inside the configured test suites is itself the unit of
|
||||||
|
* work — always run it (new untracked tests, edited tests, renames).
|
||||||
|
*
|
||||||
|
* @param list<string> $nonMigrationPaths
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
*/
|
||||||
|
private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void
|
||||||
|
{
|
||||||
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
|
$testPaths = TestPaths::fromProjectRoot($this->projectRoot);
|
||||||
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
@@ -332,9 +427,19 @@ final class Graph
|
|||||||
}
|
}
|
||||||
$affectedSet[$rel] = true;
|
$affectedSet[$rel] = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered.
|
||||||
|
*
|
||||||
|
* @param list<string> $nonMigrationPaths
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
|
||||||
|
{
|
||||||
|
$staticallyHandled = [];
|
||||||
|
|
||||||
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
|
|
||||||
$staticallyHandledBlade = [];
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
if (isset($this->fileIds[$rel])) {
|
if (isset($this->fileIds[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -348,18 +453,39 @@ final class Graph
|
|||||||
|
|
||||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||||
|
|
||||||
|
// Only a walk that actually selected tests counts as handled — a
|
||||||
|
// component whose usage the static walk missed must still reach
|
||||||
|
// the watch-pattern fallback instead of being silently swallowed.
|
||||||
if ($bladeAffected !== []) {
|
if ($bladeAffected !== []) {
|
||||||
foreach ($bladeAffected as $testFile) {
|
foreach ($bladeAffected as $testFile) {
|
||||||
$affectedSet[$testFile] = true;
|
$affectedSet[$testFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$staticallyHandledBlade[$rel] = true;
|
$staticallyHandled[$rel] = true;
|
||||||
} elseif ($this->isBladeComponentPath($rel)) {
|
|
||||||
$staticallyHandledBlade[$rel] = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $staticallyHandled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $nonMigrationPaths
|
||||||
|
* @param list<string> $unparseableMigrations
|
||||||
|
* @param array<string, true> $preciselyHandledPages
|
||||||
|
* @param array<string, true> $sharedFilesResolved
|
||||||
|
* @param array<string, true> $staticallyHandledBlade
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
*/
|
||||||
|
private function applyWatchPatternFallback(
|
||||||
|
array $nonMigrationPaths,
|
||||||
|
array $unparseableMigrations,
|
||||||
|
array $preciselyHandledPages,
|
||||||
|
array $sharedFilesResolved,
|
||||||
|
array $staticallyHandledBlade,
|
||||||
|
array &$affectedSet,
|
||||||
|
): void {
|
||||||
$unknownToGraph = $unparseableMigrations;
|
$unknownToGraph = $unparseableMigrations;
|
||||||
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
if (isset($preciselyHandledPages[$rel])) {
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -388,8 +514,18 @@ final class Graph
|
|||||||
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
||||||
$affectedSet[$testFile] = true;
|
$affectedSet[$testFile] = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $unknownSourceDirs
|
||||||
|
* @param array<string, true> $affectedSet
|
||||||
|
*/
|
||||||
|
private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void
|
||||||
|
{
|
||||||
|
if ($unknownSourceDirs === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($unknownSourceDirs !== []) {
|
|
||||||
foreach ($this->edges as $testFile => $ids) {
|
foreach ($this->edges as $testFile => $ids) {
|
||||||
if (isset($affectedSet[$testFile])) {
|
if (isset($affectedSet[$testFile])) {
|
||||||
continue;
|
continue;
|
||||||
@@ -411,9 +547,6 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_keys($affectedSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function knowsTest(string $testFile): bool
|
public function knowsTest(string $testFile): bool
|
||||||
{
|
{
|
||||||
$rel = $this->relative($testFile);
|
$rel = $this->relative($testFile);
|
||||||
@@ -516,13 +649,13 @@ final class Graph
|
|||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public function failedOrErroredTestFiles(string $branch, string $fallbackBranch = 'main'): array
|
public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array
|
||||||
{
|
{
|
||||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|
||||||
foreach ($baseline['results'] as $result) {
|
foreach ($baseline['results'] as $result) {
|
||||||
if ($result['status'] !== 7 && $result['status'] !== 8) {
|
if (! $this->shouldRerun($result['status'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,12 +677,12 @@ final class Graph
|
|||||||
return array_keys($files);
|
return array_keys($files);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasUnlocatedFailuresOrErrors(string $branch, string $fallbackBranch = 'main'): bool
|
public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
|
||||||
{
|
{
|
||||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
foreach ($baseline['results'] as $result) {
|
foreach ($baseline['results'] as $result) {
|
||||||
if ($result['status'] !== 7 && $result['status'] !== 8) {
|
if (! $this->shouldRerun($result['status'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +696,71 @@ final class Graph
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldRerun(int $status): bool
|
||||||
|
{
|
||||||
|
return $this->shouldRerunStatus(TestStatus::from($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a cached result with this status must be re-executed rather
|
||||||
|
* than replayed, honouring the configured failOn* / displayDetailsOn*
|
||||||
|
* policies.
|
||||||
|
*/
|
||||||
|
public function shouldRerunStatus(TestStatus $testStatus): bool
|
||||||
|
{
|
||||||
|
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration = Registry::get();
|
||||||
|
|
||||||
|
if ($testStatus->isRisky()) {
|
||||||
|
return $configuration->failOnRisky();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testStatus->isWarning()) {
|
||||||
|
if ($configuration->failOnWarning()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration->displayDetailsOnTestsThatTriggerWarnings();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testStatus->isNotice()) {
|
||||||
|
if ($configuration->failOnNotice()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration->displayDetailsOnTestsThatTriggerNotices();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testStatus->isDeprecation()) {
|
||||||
|
if ($configuration->failOnDeprecation()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testStatus->isIncomplete()) {
|
||||||
|
if ($configuration->failOnIncomplete()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration->displayDetailsOnIncompleteTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($testStatus->isSkipped()) {
|
||||||
|
if ($configuration->failOnSkipped()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $configuration->displayDetailsOnSkippedTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $tree project-relative path → content hash
|
* @param array<string, string> $tree project-relative path → content hash
|
||||||
*/
|
*/
|
||||||
@@ -631,6 +829,38 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark test files that executed under a recorded coverage session as "known",
|
||||||
|
* seeding an empty edge set for any that produced zero project-source edges.
|
||||||
|
*
|
||||||
|
* Without this, a test that covers no application source (e.g. a pure unit
|
||||||
|
* test asserting on language primitives) never becomes an edge key, so
|
||||||
|
* {@see self::knowsTest()} reports it as unknown and it re-runs on every TIA
|
||||||
|
* run. Recording it with an empty edge set lets it be replayed/skipped while
|
||||||
|
* unchanged; it is still re-run whenever its own file changes, via
|
||||||
|
* {@see self::applyTestFileChanges()}.
|
||||||
|
*
|
||||||
|
* Must only be called from the recording path, where coverage was actually
|
||||||
|
* collected — otherwise a missing edge set could mean "coverage was off",
|
||||||
|
* not "genuinely covered nothing".
|
||||||
|
*
|
||||||
|
* @param array<int, string> $testFiles Absolute or project-relative test file paths.
|
||||||
|
*/
|
||||||
|
public function markKnownTestFiles(array $testFiles): void
|
||||||
|
{
|
||||||
|
foreach ($testFiles as $testFile) {
|
||||||
|
$rel = $this->relative($testFile);
|
||||||
|
|
||||||
|
if ($rel === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($this->edges[$rel])) {
|
||||||
|
$this->edges[$rel] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<int, string>> $testToTables
|
* @param array<string, array<int, string>> $testToTables
|
||||||
*/
|
*/
|
||||||
@@ -822,29 +1052,16 @@ final class Graph
|
|||||||
return $this->archTestFiles;
|
return $this->archTestFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function methodHasGroup(object $method, string $group): bool
|
private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
|
||||||
{
|
{
|
||||||
if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) {
|
if (in_array($group, $method->groups, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($method->attributes as $attribute) {
|
foreach ($method->attributes as $attribute) {
|
||||||
if (! is_object($attribute)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! property_exists($attribute, 'name')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($attribute->name !== Group::class) {
|
if ($attribute->name !== Group::class) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! property_exists($attribute, 'arguments')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($attribute->arguments as $argument) {
|
foreach ($attribute->arguments as $argument) {
|
||||||
if ($argument === $group) {
|
if ($argument === $group) {
|
||||||
@@ -978,9 +1195,8 @@ final class Graph
|
|||||||
);
|
);
|
||||||
|
|
||||||
foreach ($iterator as $file) {
|
foreach ($iterator as $file) {
|
||||||
if (! $file instanceof \SplFileInfo) {
|
assert($file instanceof \SplFileInfo);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! $file->isFile()) {
|
if (! $file->isFile()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1049,7 +1265,21 @@ final class Graph
|
|||||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||||
$name = str_replace('/', '.', $tail);
|
$name = str_replace('/', '.', $tail);
|
||||||
|
|
||||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
if ($name === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [$name, str_replace('_', '-', $name)];
|
||||||
|
|
||||||
|
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||||
|
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||||
|
$base = substr($name, 0, -strlen('.index'));
|
||||||
|
|
||||||
|
$names[] = $base;
|
||||||
|
$names[] = str_replace('_', '-', $base);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($names));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
@@ -1121,13 +1351,7 @@ final class Graph
|
|||||||
/** @param array<string, array<int, string>> $edges */
|
/** @param array<string, array<int, string>> $edges */
|
||||||
private function anyTestUses(array $edges, string $component): bool
|
private function anyTestUses(array $edges, string $component): bool
|
||||||
{
|
{
|
||||||
foreach ($edges as $components) {
|
return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
|
||||||
if (in_array($component, $components, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pruneMissingTests(): void
|
public function pruneMissingTests(): void
|
||||||
@@ -1153,6 +1377,51 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune baseline result entries whose test files were just executed but whose
|
||||||
|
* test IDs are no longer present (e.g. the test method was removed or renamed).
|
||||||
|
*
|
||||||
|
* @param array<int, string> $touchedFiles Absolute or project-relative paths.
|
||||||
|
* @param array<int, string> $keepTestIds Test IDs that produced a result this run.
|
||||||
|
*/
|
||||||
|
public function pruneStaleResults(string $branch, array $touchedFiles, array $keepTestIds): void
|
||||||
|
{
|
||||||
|
if (! isset($this->baselines[$branch]['results'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touched = [];
|
||||||
|
foreach ($touchedFiles as $file) {
|
||||||
|
$rel = $this->relative($file);
|
||||||
|
|
||||||
|
if ($rel !== null) {
|
||||||
|
$touched[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($touched === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = array_fill_keys($keepTestIds, true);
|
||||||
|
|
||||||
|
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
|
||||||
|
$file = $result['file'] ?? null;
|
||||||
|
if (! is_string($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! isset($touched[$file])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($keep[$testId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->baselines[$branch]['results'][$testId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function decode(string $json, string $projectRoot): ?self
|
public static function decode(string $json, string $projectRoot): ?self
|
||||||
{
|
{
|
||||||
$data = json_decode($json, true);
|
$data = json_decode($json, true);
|
||||||
@@ -1168,78 +1437,51 @@ final class Graph
|
|||||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||||
|
|
||||||
if (isset($data['test_tables']) && is_array($data['test_tables'])) {
|
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
||||||
foreach ($data['test_tables'] as $testRel => $tables) {
|
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
||||||
if (! is_string($testRel)) {
|
$graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($tables)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$names = [];
|
|
||||||
|
|
||||||
foreach ($tables as $table) {
|
|
||||||
if (is_string($table) && $table !== '') {
|
|
||||||
$names[] = $table;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($names !== []) {
|
|
||||||
$graph->testTables[$testRel] = $names;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
|
|
||||||
foreach ($data['test_inertia_components'] as $testRel => $components) {
|
|
||||||
if (! is_string($testRel)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($components)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$names = [];
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
|
||||||
if (is_string($component) && $component !== '') {
|
|
||||||
$names[] = $component;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($names !== []) {
|
|
||||||
$graph->testInertiaComponents[$testRel] = $names;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
|
|
||||||
foreach ($data['js_file_to_components'] as $path => $components) {
|
|
||||||
if (! is_string($path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($path === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($components)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$names = [];
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
|
||||||
if (is_string($component) && $component !== '') {
|
|
||||||
$names[] = $component;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($names !== []) {
|
|
||||||
$graph->jsFileToComponents[$path] = $names;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $graph;
|
return $graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
private static function decodeStringMap(mixed $section): array
|
||||||
|
{
|
||||||
|
if (! is_array($section)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($section as $key => $values) {
|
||||||
|
if (! is_string($key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($values)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
$names[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($names !== []) {
|
||||||
|
$out[$key] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
public function encode(): ?string
|
public function encode(): ?string
|
||||||
{
|
{
|
||||||
$payload = [
|
$payload = [
|
||||||
@@ -1273,7 +1515,11 @@ final class Graph
|
|||||||
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||||
|| (strlen($path) >= 2 && $path[1] === ':');
|
|| (strlen($path) >= 2 && $path[1] === ':');
|
||||||
if ($isAbsolute) {
|
if ($isAbsolute) {
|
||||||
$real = @realpath($path);
|
if (array_key_exists($path, $this->realpathCache)) {
|
||||||
|
$real = $this->realpathCache[$path];
|
||||||
|
} else {
|
||||||
|
$real = $this->realpathCache[$path] = @realpath($path);
|
||||||
|
}
|
||||||
|
|
||||||
if ($real === false) {
|
if ($real === false) {
|
||||||
$real = $path;
|
$real = $path;
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class JsImportParser
|
|
||||||
{
|
|
||||||
private const array PAGE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte'];
|
|
||||||
|
|
||||||
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
|
|
||||||
|
|
||||||
private const string JS_DIR = 'resources/js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>
|
|
||||||
*/
|
|
||||||
public static function parse(string $projectRoot): array
|
|
||||||
{
|
|
||||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
|
|
||||||
$pagesRoot = null;
|
|
||||||
|
|
||||||
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
|
|
||||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
|
|
||||||
if (is_dir($abs)) {
|
|
||||||
$pagesRoot = $abs;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pagesRoot === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reverse = [];
|
|
||||||
|
|
||||||
foreach (self::collectPages($pagesRoot) as $pageAbs) {
|
|
||||||
$component = self::componentName($pagesRoot, $pageAbs);
|
|
||||||
|
|
||||||
if ($component === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$visited = [];
|
|
||||||
self::collectTransitive($pageAbs, $projectRoot, $jsRoot, $visited);
|
|
||||||
|
|
||||||
foreach (array_keys($visited) as $depAbs) {
|
|
||||||
if ($depAbs === $pageAbs) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($depAbs, strlen($projectRoot) + 1));
|
|
||||||
$reverse[$rel][$component] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($reverse as $path => $components) {
|
|
||||||
$names = array_keys($components);
|
|
||||||
sort($names);
|
|
||||||
$out[$path] = $names;
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($out);
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function collectPages(string $pagesRoot): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($iterator as $fileInfo) {
|
|
||||||
if (! $fileInfo->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ext = strtolower((string) $fileInfo->getExtension());
|
|
||||||
if (in_array($ext, self::PAGE_EXTENSIONS, true)) {
|
|
||||||
$out[] = $fileInfo->getPathname();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function componentName(string $pagesRoot, string $pageAbs): ?string
|
|
||||||
{
|
|
||||||
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($pageAbs, strlen($pagesRoot) + 1));
|
|
||||||
$dot = strrpos($rel, '.');
|
|
||||||
|
|
||||||
if ($dot === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = substr($rel, 0, $dot);
|
|
||||||
|
|
||||||
return $name === '' ? null : $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, true> $visited
|
|
||||||
*/
|
|
||||||
private static function collectTransitive(string $fileAbs, string $projectRoot, string $jsRoot, array &$visited): void
|
|
||||||
{
|
|
||||||
if (isset($visited[$fileAbs])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$visited[$fileAbs] = true;
|
|
||||||
|
|
||||||
$source = self::loadSource($fileAbs);
|
|
||||||
if ($source === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::extractImports($source) as $spec) {
|
|
||||||
$resolved = self::resolveImport($spec, $fileAbs, $jsRoot);
|
|
||||||
if ($resolved === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_file($resolved)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::collectTransitive($resolved, $projectRoot, $jsRoot, $visited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function loadSource(string $fileAbs): ?string
|
|
||||||
{
|
|
||||||
$content = @file_get_contents($fileAbs);
|
|
||||||
if ($content === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_ends_with(strtolower($fileAbs), '.vue')) {
|
|
||||||
$scripts = [];
|
|
||||||
if (preg_match_all('/<script[^>]*>(.*?)<\/script>/si', $content, $m) !== false) {
|
|
||||||
foreach ($m[1] as $block) {
|
|
||||||
$scripts[] = $block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\n", $scripts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function extractImports(string $source): array
|
|
||||||
{
|
|
||||||
$stripped = preg_replace('#//[^\n]*#', '', $source) ?? $source;
|
|
||||||
$stripped = preg_replace('#/\*.*?\*/#s', '', $stripped) ?? $stripped;
|
|
||||||
|
|
||||||
$specs = [];
|
|
||||||
|
|
||||||
if (preg_match_all('/\bimport\s+(?:[^\'"()]*?\s+from\s+)?[\'"]([^\'"]+)[\'"]/', $stripped, $matches) !== false) {
|
|
||||||
foreach ($matches[1] as $spec) {
|
|
||||||
$specs[] = $spec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match_all('/\bimport\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $stripped, $matches) !== false) {
|
|
||||||
foreach ($matches[1] as $spec) {
|
|
||||||
$specs[] = $spec;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $specs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveImport(string $spec, string $importerAbs, string $jsRoot): ?string
|
|
||||||
{
|
|
||||||
if ($spec === '' || $spec[0] === '.' || $spec[0] === '/') {
|
|
||||||
return self::resolveRelative($spec, $importerAbs);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($spec, '@/') || str_starts_with($spec, '~/')) {
|
|
||||||
$tail = substr($spec, 2);
|
|
||||||
|
|
||||||
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveRelative(string $spec, string $importerAbs): ?string
|
|
||||||
{
|
|
||||||
if ($spec === '' || $spec[0] === '/') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$base = dirname($importerAbs);
|
|
||||||
$path = $base.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $spec);
|
|
||||||
|
|
||||||
return self::withExtension($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function withExtension(string $path): ?string
|
|
||||||
{
|
|
||||||
if (is_file($path)) {
|
|
||||||
return realpath($path) ?: $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
|
|
||||||
$candidate = $path.'.'.$ext;
|
|
||||||
if (is_file($candidate)) {
|
|
||||||
return realpath($candidate) ?: $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
|
|
||||||
$candidate = $path.DIRECTORY_SEPARATOR.'index.'.$ext;
|
|
||||||
if (is_file($candidate)) {
|
|
||||||
return realpath($candidate) ?: $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,42 @@ final class JsModuleGraph
|
|||||||
|
|
||||||
private const string CACHE_FILE = 'js-module-graph.cache.json';
|
private const string CACHE_FILE = 'js-module-graph.cache.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const array VITE_CONFIG_NAMES = [
|
||||||
|
'vite.config.ts',
|
||||||
|
'vite.config.js',
|
||||||
|
'vite.config.mjs',
|
||||||
|
'vite.config.cjs',
|
||||||
|
'vite.config.mts',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate page directories, in priority order. Must stay in sync with
|
||||||
|
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const array PAGE_DIR_CANDIDATES = [
|
||||||
|
'resources/js/Pages',
|
||||||
|
'resources/js/pages',
|
||||||
|
'assets/js/Pages',
|
||||||
|
'assets/js/pages',
|
||||||
|
'assets/Pages',
|
||||||
|
'assets/pages',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const array PAGE_EXTENSIONS = [
|
||||||
|
'vue', 'svelte',
|
||||||
|
'tsx', 'jsx',
|
||||||
|
'ts', 'js',
|
||||||
|
'mts', 'cts', 'mjs', 'cjs',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, list<string>>
|
* @return array<string, list<string>>
|
||||||
*/
|
*/
|
||||||
@@ -40,8 +76,44 @@ final class JsModuleGraph
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['Pages', 'pages'] as $dir) {
|
return self::firstExistingPagesDir($projectRoot) !== null;
|
||||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
}
|
||||||
|
|
||||||
|
private static function firstExistingPagesDir(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
|
||||||
|
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
||||||
|
|
||||||
|
if (! is_dir($abs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::dirHasPageFile($abs)) {
|
||||||
|
return $abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function dirHasPageFile(string $dir): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||||
|
);
|
||||||
|
} catch (\UnexpectedValueException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \SplFileInfo $file */
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +163,9 @@ final class JsModuleGraph
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||||
|
|
||||||
|
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +229,9 @@ final class JsModuleGraph
|
|||||||
|
|
||||||
private static function fingerprint(string $projectRoot): ?string
|
private static function fingerprint(string $projectRoot): ?string
|
||||||
{
|
{
|
||||||
if (! self::hasViteConfig($projectRoot)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
||||||
|
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
@@ -177,17 +247,25 @@ final class JsModuleGraph
|
|||||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (['Pages', 'pages'] as $dir) {
|
if ($parts === []) {
|
||||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
return null;
|
||||||
$parts[] = 'pagesDir:'.$dir;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js';
|
$override = getenv('TIA_VITE_PAGES_DIR');
|
||||||
|
|
||||||
if (is_dir($jsRoot)) {
|
if (is_string($override) && $override !== '') {
|
||||||
|
$parts[] = 'pagesDirOverride:'.$override;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pagesDir = self::firstExistingPagesDir($projectRoot);
|
||||||
|
|
||||||
|
if ($pagesDir !== null) {
|
||||||
|
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
|
||||||
|
|
||||||
|
if ($jsRoot !== null && is_dir($jsRoot)) {
|
||||||
$entries = [];
|
$entries = [];
|
||||||
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
@@ -310,12 +388,6 @@ final class JsModuleGraph
|
|||||||
|
|
||||||
private static function hasViteConfig(string $projectRoot): bool
|
private static function hasViteConfig(string $projectRoot): bool
|
||||||
{
|
{
|
||||||
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
||||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Lockfiles;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class PackageLock implements Lockfile
|
||||||
|
{
|
||||||
|
public function applies(string $filename): bool
|
||||||
|
{
|
||||||
|
return $filename === 'package-lock.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fingerprint(string $contents): ?string
|
||||||
|
{
|
||||||
|
$data = json_decode($contents, true);
|
||||||
|
|
||||||
|
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packages = $data['packages'];
|
||||||
|
|
||||||
|
$platformPaths = [];
|
||||||
|
|
||||||
|
foreach ($packages as $path => $meta) {
|
||||||
|
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
|
||||||
|
$platformPaths[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($packages as $path => $meta) {
|
||||||
|
if (! is_string($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($meta)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->isPlatformSpecific($meta)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$version = $meta['version'] ?? null;
|
||||||
|
|
||||||
|
$entries[$path] = is_string($version) ? $version : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($entries);
|
||||||
|
|
||||||
|
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return $encoded === false ? null : hash('xxh128', $encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function isPlatformSpecific(array $meta): bool
|
||||||
|
{
|
||||||
|
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $platformPaths
|
||||||
|
*/
|
||||||
|
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
|
||||||
|
{
|
||||||
|
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-349
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
|
||||||
@@ -33,23 +32,10 @@ final class Recorder
|
|||||||
/** @var array<string, bool> */
|
/** @var array<string, bool> */
|
||||||
private array $classUsesDatabaseCache = [];
|
private array $classUsesDatabaseCache = [];
|
||||||
|
|
||||||
/** @var array<string, list<string>> */
|
|
||||||
private array $fileToClassNames = [];
|
|
||||||
|
|
||||||
/** @var array<string, true> */
|
|
||||||
private array $indexedClassNames = [];
|
|
||||||
|
|
||||||
/** @var array<string, list<string>> */
|
|
||||||
private array $classDependencyCache = [];
|
|
||||||
|
|
||||||
/** @var array<string, list<string>> */
|
|
||||||
private array $testImportFileCache = [];
|
|
||||||
|
|
||||||
/** @var array<string, true> */
|
|
||||||
private array $includedFilesAtTestStart = [];
|
|
||||||
|
|
||||||
private bool $active = false;
|
private bool $active = false;
|
||||||
|
|
||||||
|
private bool $captureCoverage = false;
|
||||||
|
|
||||||
private bool $driverChecked = false;
|
private bool $driverChecked = false;
|
||||||
|
|
||||||
private bool $driverAvailable = false;
|
private bool $driverAvailable = false;
|
||||||
@@ -59,6 +45,17 @@ final class Recorder
|
|||||||
private ?SourceScope $sourceScope = null;
|
private ?SourceScope $sourceScope = null;
|
||||||
|
|
||||||
public function activate(): void
|
public function activate(): void
|
||||||
|
{
|
||||||
|
$this->active = true;
|
||||||
|
$this->captureCoverage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable per-test link tracking (tables, Inertia components, database
|
||||||
|
* usage, rendered views) without driving pcov/xdebug — for runs where
|
||||||
|
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
||||||
|
*/
|
||||||
|
public function activateLinkTracking(): void
|
||||||
{
|
{
|
||||||
$this->active = true;
|
$this->active = true;
|
||||||
}
|
}
|
||||||
@@ -89,16 +86,13 @@ final class Recorder
|
|||||||
return $this->driverAvailable;
|
return $this->driverAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function driver(): string
|
|
||||||
{
|
|
||||||
$this->driverAvailable();
|
|
||||||
|
|
||||||
return $this->driver;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||||
{
|
{
|
||||||
if (! $this->active || ! $this->driverAvailable()) {
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,14 +107,14 @@ final class Recorder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->currentTestFile = $file;
|
$this->currentTestFile = $file;
|
||||||
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
|
|
||||||
|
|
||||||
if ($this->classUsesDatabase($className)) {
|
if ($this->classUsesDatabase($className)) {
|
||||||
$this->perTestUsesDatabase[$file] = true;
|
$this->perTestUsesDatabase[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->linkAncestorFiles($className);
|
if (! $this->captureCoverage) {
|
||||||
$this->linkImportedFiles($file);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
@@ -134,7 +128,13 @@ final class Recorder
|
|||||||
|
|
||||||
public function endTest(): void
|
public function endTest(): void
|
||||||
{
|
{
|
||||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
if (! $this->active || $this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||||
|
$this->currentTestFile = null;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,26 +159,22 @@ final class Recorder
|
|||||||
$data = \xdebug_get_code_coverage();
|
$data = \xdebug_get_code_coverage();
|
||||||
\xdebug_stop_code_coverage(true);
|
\xdebug_stop_code_coverage(true);
|
||||||
|
|
||||||
$coveredFiles = array_keys($data);
|
$scope = $this->sourceScope();
|
||||||
|
|
||||||
|
foreach (array_keys($data) as $file) {
|
||||||
|
if (! $scope->contains($file)) {
|
||||||
|
unset($data[$file]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($coveredFiles as $sourceFile) {
|
foreach ($coveredFiles as $sourceFile) {
|
||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (AutoloadEdges::newProjectFiles(
|
|
||||||
$this->includedFilesAtTestStart,
|
|
||||||
AutoloadEdges::snapshot(),
|
|
||||||
TestSuite::getInstance()->rootPath,
|
|
||||||
$this->currentTestFile,
|
|
||||||
) as $sourceFile) {
|
|
||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->linkSourceDependencies($coveredFiles);
|
|
||||||
|
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
$this->includedFilesAtTestStart = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function linkSource(string $sourceFile): void
|
public function linkSource(string $sourceFile): void
|
||||||
@@ -198,295 +194,6 @@ final class Recorder
|
|||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param iterable<int, string> $sourceFiles */
|
|
||||||
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
|
|
||||||
{
|
|
||||||
if (! $this->active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($testFile === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($sourceFiles as $sourceFile) {
|
|
||||||
if ($sourceFile === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->perTestFiles[$testFile][$sourceFile] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int, string> $coveredFiles */
|
|
||||||
private function linkSourceDependencies(array $coveredFiles): void
|
|
||||||
{
|
|
||||||
if ($this->currentTestFile === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->refreshClassMap();
|
|
||||||
|
|
||||||
foreach ($coveredFiles as $coveredFile) {
|
|
||||||
if (! isset($this->fileToClassNames[$coveredFile])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->fileToClassNames[$coveredFile] as $name) {
|
|
||||||
foreach ($this->classDependencies($name) as $depFile) {
|
|
||||||
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function refreshClassMap(): void
|
|
||||||
{
|
|
||||||
$names = array_merge(
|
|
||||||
get_declared_classes(),
|
|
||||||
get_declared_interfaces(),
|
|
||||||
get_declared_traits(),
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($names as $name) {
|
|
||||||
if (isset($this->indexedClassNames[$name])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$this->indexedClassNames[$name] = true;
|
|
||||||
|
|
||||||
if (! class_exists($name, false)
|
|
||||||
&& ! interface_exists($name, false)
|
|
||||||
&& ! trait_exists($name, false)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reflection = new ReflectionClass($name);
|
|
||||||
|
|
||||||
if ($reflection->isInternal()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $reflection->getFileName();
|
|
||||||
|
|
||||||
if (! is_string($file)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->fileToClassNames[$file][] = $name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private function classDependencies(string $className): array
|
|
||||||
{
|
|
||||||
if (isset($this->classDependencyCache[$className])) {
|
|
||||||
return $this->classDependencyCache[$className];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! class_exists($className, false)
|
|
||||||
&& ! interface_exists($className, false)
|
|
||||||
&& ! trait_exists($className, false)) {
|
|
||||||
return $this->classDependencyCache[$className] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$reflection = new ReflectionClass($className);
|
|
||||||
|
|
||||||
$files = [];
|
|
||||||
|
|
||||||
$linkSymbol = static function (string $name) use (&$files): void {
|
|
||||||
if (! class_exists($name, false)
|
|
||||||
&& ! interface_exists($name, false)
|
|
||||||
&& ! trait_exists($name, false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$r = new ReflectionClass($name);
|
|
||||||
if ($r->isInternal()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$f = $r->getFileName();
|
|
||||||
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$files[$f] = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
|
||||||
$linkSymbol($iname);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($reflection->getTraitNames() as $tname) {
|
|
||||||
$linkSymbol($tname);
|
|
||||||
}
|
|
||||||
|
|
||||||
$parent = $reflection->getParentClass();
|
|
||||||
while ($parent !== false && ! $parent->isInternal()) {
|
|
||||||
$f = $parent->getFileName();
|
|
||||||
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
|
||||||
$files[$f] = true;
|
|
||||||
}
|
|
||||||
foreach ($parent->getTraitNames() as $tname) {
|
|
||||||
$linkSymbol($tname);
|
|
||||||
}
|
|
||||||
$parent = $parent->getParentClass();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->classDependencyCache[$className] = array_keys($files);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function linkAncestorFiles(string $className): void
|
|
||||||
{
|
|
||||||
if (! class_exists($className, false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reflection = new ReflectionClass($className);
|
|
||||||
$parent = $reflection->getParentClass();
|
|
||||||
|
|
||||||
while ($parent !== false) {
|
|
||||||
if ($parent->isInternal()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $parent->getFileName();
|
|
||||||
|
|
||||||
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
|
||||||
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parent = $parent->getParentClass();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function linkImportedFiles(string $testFile): void
|
|
||||||
{
|
|
||||||
if ($this->currentTestFile === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->importedFilesFor($testFile) as $file) {
|
|
||||||
$this->perTestFiles[$this->currentTestFile][$file] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function importedFilesFor(string $testFile): array
|
|
||||||
{
|
|
||||||
if (array_key_exists($testFile, $this->testImportFileCache)) {
|
|
||||||
return $this->testImportFileCache[$testFile];
|
|
||||||
}
|
|
||||||
|
|
||||||
$source = @file_get_contents($testFile);
|
|
||||||
if ($source === false) {
|
|
||||||
return $this->testImportFileCache[$testFile] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = [];
|
|
||||||
|
|
||||||
foreach ($this->importedClassNames($source) as $className) {
|
|
||||||
$file = $this->findAutoloadFile($className);
|
|
||||||
|
|
||||||
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
|
||||||
$files[$file] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->testImportFileCache[$testFile] = array_keys($files);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function importedClassNames(string $source): array
|
|
||||||
{
|
|
||||||
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
|
|
||||||
|
|
||||||
$classes = [];
|
|
||||||
|
|
||||||
foreach ($matches[1] as $import) {
|
|
||||||
$import = trim($import);
|
|
||||||
|
|
||||||
if ($import === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$open = strpos($import, '{');
|
|
||||||
$close = strrpos($import, '}');
|
|
||||||
|
|
||||||
if ($open !== false && $close !== false && $close > $open) {
|
|
||||||
$prefix = trim(trim(substr($import, 0, $open)), '\\');
|
|
||||||
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
|
|
||||||
|
|
||||||
if ($class !== null) {
|
|
||||||
$classes[$class] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$class = $this->normaliseImportedClass($import);
|
|
||||||
|
|
||||||
if ($class !== null) {
|
|
||||||
$classes[$class] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys($classes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normaliseImportedClass(string $import): ?string
|
|
||||||
{
|
|
||||||
$import = trim(trim($import), '\\');
|
|
||||||
|
|
||||||
if ($import === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = preg_split('/\s+as\s+/i', $import);
|
|
||||||
if ($parts === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$class = trim(trim($parts[0]), '\\');
|
|
||||||
|
|
||||||
return $class === '' ? null : $class;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findAutoloadFile(string $className): ?string
|
|
||||||
{
|
|
||||||
foreach (spl_autoload_functions() as $loader) {
|
|
||||||
if (! is_array($loader)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_object($loader[0])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! method_exists($loader[0], 'findFile')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var mixed $file */
|
|
||||||
$file = $loader[0]->findFile($className);
|
|
||||||
|
|
||||||
if (is_string($file) && $file !== '') {
|
|
||||||
$real = @realpath($file);
|
|
||||||
|
|
||||||
return $real === false ? $file : $real;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function classUsesDatabase(string $className): bool
|
private function classUsesDatabase(string $className): bool
|
||||||
{
|
{
|
||||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
||||||
@@ -624,23 +331,9 @@ final class Recorder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reflection = new ReflectionClass($className);
|
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
||||||
|
|
||||||
if ($reflection->hasProperty('__filename')) {
|
return $className::$__filename;
|
||||||
$property = $reflection->getProperty('__filename');
|
|
||||||
|
|
||||||
if ($property->isStatic()) {
|
|
||||||
$value = $property->getValue();
|
|
||||||
|
|
||||||
if (is_string($value)) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $reflection->getFileName();
|
|
||||||
|
|
||||||
return is_string($file) ? $file : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -691,10 +384,8 @@ final class Recorder
|
|||||||
$this->perTestUsesDatabase = [];
|
$this->perTestUsesDatabase = [];
|
||||||
$this->classFileCache = [];
|
$this->classFileCache = [];
|
||||||
$this->classUsesDatabaseCache = [];
|
$this->classUsesDatabaseCache = [];
|
||||||
$this->fileToClassNames = [];
|
|
||||||
$this->indexedClassNames = [];
|
|
||||||
$this->classDependencyCache = [];
|
|
||||||
$this->sourceScope = null;
|
$this->sourceScope = null;
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
|
$this->captureCoverage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -33,7 +35,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(0, '');
|
$this->record(TestStatus::success());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailed(string $message): void
|
public function testFailed(string $message): void
|
||||||
@@ -42,7 +44,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(7, $message);
|
$this->record(TestStatus::failure($message));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testErrored(string $message): void
|
public function testErrored(string $message): void
|
||||||
@@ -51,7 +53,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(8, $message);
|
$this->record(TestStatus::error($message));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSkipped(string $message): void
|
public function testSkipped(string $message): void
|
||||||
@@ -60,7 +62,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(1, $message);
|
$this->record(TestStatus::skipped($message));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIncomplete(string $message): void
|
public function testIncomplete(string $message): void
|
||||||
@@ -69,7 +71,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(2, $message);
|
$this->record(TestStatus::incomplete($message));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRisky(string $message): void
|
public function testRisky(string $message): void
|
||||||
@@ -78,7 +80,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(5, $message);
|
$this->record(TestStatus::risky($message));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,7 +123,7 @@ final class ResultCollector
|
|||||||
$this->startTime = null;
|
$this->startTime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function record(int $status, string $message): void
|
private function record(TestStatus $status): void
|
||||||
{
|
{
|
||||||
if ($this->currentTestId === null) {
|
if ($this->currentTestId === null) {
|
||||||
return;
|
return;
|
||||||
@@ -134,8 +136,8 @@ final class ResultCollector
|
|||||||
$existing = $this->results[$this->currentTestId] ?? null;
|
$existing = $this->results[$this->currentTestId] ?? null;
|
||||||
|
|
||||||
$this->results[$this->currentTestId] = [
|
$this->results[$this->currentTestId] = [
|
||||||
'status' => $status,
|
'status' => $status->asInt(),
|
||||||
'message' => $message,
|
'message' => $status->message(),
|
||||||
'time' => $time,
|
'time' => $time,
|
||||||
'assertions' => $existing['assertions'] ?? 0,
|
'assertions' => $existing['assertions'] ?? 0,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use PHPUnit\TextUI\Configuration\Registry;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class SourceScope
|
final class SourceScope
|
||||||
{
|
{
|
||||||
|
/** @var array<string, bool> */
|
||||||
|
private array $containsCache = [];
|
||||||
|
|
||||||
private const array TOP_LEVEL_NOISE = [
|
private const array TOP_LEVEL_NOISE = [
|
||||||
'vendor',
|
'vendor',
|
||||||
'node_modules',
|
'node_modules',
|
||||||
@@ -32,25 +38,27 @@ final readonly class SourceScope
|
|||||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
* @param list<string> $excludes Absolute, normalised directory paths.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private array $includes,
|
private readonly array $includes,
|
||||||
private array $excludes,
|
private readonly array $excludes,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromProjectRoot(string $projectRoot): self
|
public static function fromProjectRoot(string $projectRoot): self
|
||||||
{
|
{
|
||||||
$configPath = self::configPath($projectRoot);
|
|
||||||
|
|
||||||
$phpunitIncludes = [];
|
$phpunitIncludes = [];
|
||||||
$phpunitExcludes = [];
|
$phpunitExcludes = [];
|
||||||
|
|
||||||
if ($configPath !== null) {
|
try {
|
||||||
$xml = @simplexml_load_file($configPath);
|
$source = Registry::get()->source();
|
||||||
|
|
||||||
if ($xml !== false) {
|
foreach ($source->includeDirectories() as $dir) {
|
||||||
$configDir = dirname($configPath);
|
$phpunitIncludes[] = self::normalise($dir->path());
|
||||||
$phpunitIncludes = self::extractDirectories($xml, 'source/include/directory', $configDir);
|
|
||||||
$phpunitExcludes = self::extractDirectories($xml, 'source/exclude/directory', $configDir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($source->excludeDirectories() as $dir) {
|
||||||
|
$phpunitExcludes[] = self::normalise($dir->path());
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Registry not initialized — fall back to project-root scanning.
|
||||||
}
|
}
|
||||||
|
|
||||||
$rootIncludes = self::topLevelProjectDirs($projectRoot);
|
$rootIncludes = self::topLevelProjectDirs($projectRoot);
|
||||||
@@ -71,94 +79,50 @@ final readonly class SourceScope
|
|||||||
/**
|
/**
|
||||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
||||||
*/
|
*/
|
||||||
public static function testPaths(string $projectRoot): array
|
public static function testPaths(): array
|
||||||
{
|
{
|
||||||
$configPath = self::configPath($projectRoot);
|
try {
|
||||||
|
$suites = Registry::get()->testSuite();
|
||||||
if ($configPath === null) {
|
} catch (Throwable) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
$out = [];
|
||||||
$xml = @simplexml_load_file($configPath);
|
foreach ($suites as $suite) {
|
||||||
|
foreach ($suite->directories() as $directory) {
|
||||||
if ($xml === false) {
|
$out[] = self::normalise($directory->path());
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$configDir = dirname($configPath);
|
foreach ($suite->files() as $file) {
|
||||||
|
$out[] = self::normalise($file->path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return array_values(array_unique([
|
return array_values(array_unique($out));
|
||||||
...self::extractDirectories($xml, 'testsuites/testsuite/directory', $configDir),
|
|
||||||
...self::extractDirectories($xml, 'testsuites/testsuite/file', $configDir),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function contains(string $absoluteFile): bool
|
public function contains(string $absoluteFile): bool
|
||||||
{
|
{
|
||||||
|
if (isset($this->containsCache[$absoluteFile])) {
|
||||||
|
return $this->containsCache[$absoluteFile];
|
||||||
|
}
|
||||||
|
|
||||||
$real = @realpath($absoluteFile);
|
$real = @realpath($absoluteFile);
|
||||||
$candidate = $real === false ? $absoluteFile : $real;
|
$candidate = $real === false ? $absoluteFile : $real;
|
||||||
$candidate = self::normalise($candidate);
|
$candidate = self::normalise($candidate);
|
||||||
|
|
||||||
foreach ($this->excludes as $excluded) {
|
foreach ($this->excludes as $excluded) {
|
||||||
if ($this->startsWithDir($candidate, $excluded)) {
|
if ($this->startsWithDir($candidate, $excluded)) {
|
||||||
return false;
|
return $this->containsCache[$absoluteFile] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($this->includes as $included) {
|
foreach ($this->includes as $included) {
|
||||||
if ($this->startsWithDir($candidate, $included)) {
|
if ($this->startsWithDir($candidate, $included)) {
|
||||||
return true;
|
return $this->containsCache[$absoluteFile] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return $this->containsCache[$absoluteFile] = false;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function includes(): array
|
|
||||||
{
|
|
||||||
return $this->includes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function configPath(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
|
|
||||||
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
|
||||||
|
|
||||||
if (is_file($candidate)) {
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function extractDirectories(\SimpleXMLElement $xml, string $xpath, string $configDir): array
|
|
||||||
{
|
|
||||||
$nodes = $xml->xpath($xpath);
|
|
||||||
|
|
||||||
if (! is_array($nodes)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($nodes as $node) {
|
|
||||||
$value = trim((string) $node);
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = self::resolveRelative($value, $configDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($out));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,22 +180,6 @@ final readonly class SourceScope
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function resolveRelative(string $path, string $configDir): string
|
|
||||||
{
|
|
||||||
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|
|
||||||
|| (strlen($path) >= 2 && $path[1] === ':'));
|
|
||||||
|
|
||||||
$combined = $isAbsolute ? $path : $configDir.DIRECTORY_SEPARATOR.$path;
|
|
||||||
|
|
||||||
$real = @realpath($combined);
|
|
||||||
|
|
||||||
if ($real === false) {
|
|
||||||
return self::normalise($combined);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::normalise($real);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalise(string $path): string
|
private static function normalise(string $path): string
|
||||||
{
|
{
|
||||||
return rtrim($path, '/\\');
|
return rtrim($path, '/\\');
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
|
|||||||
*/
|
*/
|
||||||
final class TableExtractor
|
final class TableExtractor
|
||||||
{
|
{
|
||||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single (optionally quoted) identifier segment.
|
||||||
|
*/
|
||||||
|
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string> Sorted, deduped table names referenced by the
|
* @return list<string> Sorted, deduped table names referenced by the
|
||||||
@@ -22,22 +27,15 @@ final class TableExtractor
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||||
|
|
||||||
$matched = false;
|
|
||||||
foreach (self::DML_PREFIXES as $dml) {
|
|
||||||
if (str_starts_with($prefix, $dml)) {
|
|
||||||
$matched = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $matched) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||||
|
|
||||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||||
return [];
|
return [];
|
||||||
@@ -45,14 +43,9 @@ final class TableExtractor
|
|||||||
|
|
||||||
$tables = [];
|
$tables = [];
|
||||||
|
|
||||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
foreach ($matches[1] as $qualified) {
|
||||||
$name = $matches[1][$i] !== ''
|
$name = self::unqualified($qualified);
|
||||||
? $matches[1][$i]
|
|
||||||
: ($matches[2][$i] !== ''
|
|
||||||
? $matches[2][$i]
|
|
||||||
: ($matches[3][$i] !== ''
|
|
||||||
? $matches[3][$i]
|
|
||||||
: $matches[4][$i]));
|
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -80,40 +73,40 @@ final class TableExtractor
|
|||||||
|
|
||||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||||
foreach ($matches[1] as $i => $primary) {
|
foreach ($matches[1] as $i => $primary) {
|
||||||
$tables[strtolower($primary)] = true;
|
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||||
|
|
||||||
$secondary = $matches[2][$i] ?? '';
|
$secondary = $matches[2][$i] ?? '';
|
||||||
if ($secondary !== '') {
|
if ($secondary !== '') {
|
||||||
$tables[strtolower($secondary)] = true;
|
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||||
|
|
||||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
$sqlPatterns = [
|
||||||
foreach ($matches[1] as $primary) {
|
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
||||||
$lower = strtolower($primary);
|
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
||||||
if (! self::isSchemaMeta($lower)) {
|
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
||||||
|
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlPatterns as $pattern) {
|
||||||
|
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($matches[1] as $name) {
|
||||||
|
$lower = strtolower(self::unqualified($name));
|
||||||
|
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||||
$tables[$lower] = true;
|
$tables[$lower] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$dmlPatterns = [
|
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
|
||||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
|
||||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
|
||||||
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
|
|
||||||
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($dmlPatterns as $pattern) {
|
|
||||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($matches[1] as $name) {
|
foreach ($matches[1] as $name) {
|
||||||
$lower = strtolower($name);
|
$lower = strtolower(self::lastDottedSegment($name));
|
||||||
if (! self::isSchemaMeta($lower)) {
|
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||||
$tables[$lower] = true;
|
$tables[$lower] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +118,39 @@ final class TableExtractor
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table segment of a possibly schema-qualified identifier chain,
|
||||||
|
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
||||||
|
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
||||||
|
*/
|
||||||
|
private static function unqualified(string $qualified): string
|
||||||
|
{
|
||||||
|
$name = '';
|
||||||
|
|
||||||
|
foreach (explode('.', $qualified) as $segment) {
|
||||||
|
$segment = trim($segment, " \t\n\r\"`[]");
|
||||||
|
|
||||||
|
if ($segment === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isSchemaMeta($segment)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lastDottedSegment(string $name): string
|
||||||
|
{
|
||||||
|
$position = strrpos($name, '.');
|
||||||
|
|
||||||
|
return $position === false ? $name : substr($name, $position + 1);
|
||||||
|
}
|
||||||
|
|
||||||
private static function isSchemaMeta(string $name): bool
|
private static function isSchemaMeta(string $name): bool
|
||||||
{
|
{
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
use PHPUnit\TextUI\Configuration\Registry;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the set of project-relative paths that are considered test files,
|
* Resolves the set of project-relative paths that are considered test files,
|
||||||
@@ -28,39 +30,44 @@ final readonly class TestPaths
|
|||||||
|
|
||||||
public static function fromProjectRoot(string $projectRoot): self
|
public static function fromProjectRoot(string $projectRoot): self
|
||||||
{
|
{
|
||||||
$configPath = self::configPath($projectRoot);
|
|
||||||
|
|
||||||
$directories = [];
|
$directories = [];
|
||||||
$files = [];
|
$files = [];
|
||||||
$suffixes = ['.php'];
|
$suffixes = [];
|
||||||
|
|
||||||
if ($configPath !== null) {
|
try {
|
||||||
$xml = @simplexml_load_file($configPath);
|
$configuration = Registry::get();
|
||||||
|
|
||||||
if ($xml !== false) {
|
foreach ($configuration->testSuite() as $suite) {
|
||||||
$configDir = dirname($configPath);
|
foreach ($suite->directories() as $directory) {
|
||||||
|
$rel = self::toRelative($directory->path(), $projectRoot);
|
||||||
foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) {
|
|
||||||
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
|
|
||||||
|
|
||||||
if ($rel !== null) {
|
if ($rel !== null) {
|
||||||
$directories[] = $rel;
|
$directories[] = $rel;
|
||||||
}
|
}
|
||||||
|
|
||||||
$suffix = (string) ($node['suffix'] ?? '');
|
$suffixes[] = $directory->suffix();
|
||||||
if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) {
|
|
||||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) {
|
foreach ($suite->files() as $file) {
|
||||||
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
|
$rel = self::toRelative($file->path(), $projectRoot);
|
||||||
|
|
||||||
if ($rel !== null) {
|
if ($rel !== null) {
|
||||||
$files[] = $rel;
|
$files[] = $rel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($suffixes === []) {
|
||||||
|
foreach ($configuration->testSuffixes() as $suffix) {
|
||||||
|
$suffixes[] = $suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Registry not initialized — fall through to defaults.
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($suffixes === []) {
|
||||||
|
$suffixes = ['.php'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($directories === [] && $files === []) {
|
if ($directories === [] && $files === []) {
|
||||||
@@ -83,15 +90,7 @@ final readonly class TestPaths
|
|||||||
if (in_array($relativePath, $this->files, true)) {
|
if (in_array($relativePath, $this->files, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
|
||||||
$matchesSuffix = false;
|
|
||||||
foreach ($this->suffixes as $suffix) {
|
|
||||||
if (str_ends_with($relativePath, $suffix)) {
|
|
||||||
$matchesSuffix = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $matchesSuffix) {
|
if (! $matchesSuffix) {
|
||||||
return false;
|
return false;
|
||||||
@@ -109,20 +108,7 @@ final readonly class TestPaths
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function configPath(string $projectRoot): ?string
|
private static function toRelative(string $value, string $projectRoot): ?string
|
||||||
{
|
|
||||||
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
|
|
||||||
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
|
||||||
|
|
||||||
if (is_file($candidate)) {
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function toRelative(string $value, string $configDir, string $projectRoot): ?string
|
|
||||||
{
|
{
|
||||||
$value = trim($value);
|
$value = trim($value);
|
||||||
|
|
||||||
@@ -130,13 +116,8 @@ final readonly class TestPaths
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR
|
$real = @realpath($value);
|
||||||
|| (strlen($value) >= 2 && $value[1] === ':');
|
$resolved = $real === false ? $value : $real;
|
||||||
|
|
||||||
$combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value;
|
|
||||||
|
|
||||||
$real = @realpath($combined);
|
|
||||||
$resolved = $real === false ? $combined : $real;
|
|
||||||
|
|
||||||
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
||||||
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
||||||
@@ -152,7 +133,7 @@ final readonly class TestPaths
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$testPath = TestSuite::getInstance()->testPath;
|
$testPath = TestSuite::getInstance()->testPath;
|
||||||
} catch (\Throwable) {
|
} catch (Throwable) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
|||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Pest\Browser\Support\BrowserTestIdentifier;
|
use Pest\Browser\Support\BrowserTestIdentifier;
|
||||||
use Pest\Factories\TestCaseFactory;
|
use Pest\Factories\TestCaseFactory;
|
||||||
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,29 +26,10 @@ final readonly class Browser implements WatchDefault
|
|||||||
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
|
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
|
||||||
|
|
||||||
$globs = [
|
$globs = [
|
||||||
'resources/js/**/*.js',
|
'resources/js/** !*.php',
|
||||||
'resources/js/**/*.ts',
|
'resources/css/** !*.php',
|
||||||
'resources/js/**/*.tsx',
|
'public/hot !*.php',
|
||||||
'resources/js/**/*.jsx',
|
'public/** !*.php',
|
||||||
'resources/js/**/*.vue',
|
|
||||||
'resources/js/**/*.svelte',
|
|
||||||
'resources/css/**/*.css',
|
|
||||||
'resources/css/**/*.scss',
|
|
||||||
'resources/css/**/*.less',
|
|
||||||
'public/build/**/*.js',
|
|
||||||
'public/build/**/*.css',
|
|
||||||
'public/**/*.js',
|
|
||||||
'public/**/*.css',
|
|
||||||
'public/**/*.svg',
|
|
||||||
'public/**/*.png',
|
|
||||||
'public/**/*.jpg',
|
|
||||||
'public/**/*.jpeg',
|
|
||||||
'public/**/*.webp',
|
|
||||||
'public/**/*.ico',
|
|
||||||
'public/**/*.txt',
|
|
||||||
'public/**/*.json',
|
|
||||||
'public/**/*.xml',
|
|
||||||
'public/hot',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$patterns = [];
|
$patterns = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -20,27 +21,8 @@ final readonly class Inertia implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
|
return [
|
||||||
|
'resources/js/** !*.php' => [$testPath],
|
||||||
$patterns = [];
|
];
|
||||||
|
|
||||||
foreach (['Pages', 'pages'] as $pages) {
|
|
||||||
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
|
|
||||||
$patterns["resources/js/{$pages}/**/*.{$ext}"] = $browserTargets;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
|
|
||||||
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
|
|
||||||
$patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$patterns['resources/js/ssr.js'] = $browserTargets;
|
|
||||||
$patterns['resources/js/ssr.ts'] = $browserTargets;
|
|
||||||
$patterns['resources/js/app.js'] = $browserTargets;
|
|
||||||
$patterns['resources/js/app.ts'] = $browserTargets;
|
|
||||||
|
|
||||||
return $patterns;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -20,46 +21,21 @@ final readonly class Laravel implements WatchDefault
|
|||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'config/*.php' => [$testPath],
|
|
||||||
'config/**/*.php' => [$testPath],
|
|
||||||
|
|
||||||
'routes/*.php' => [$testPath],
|
|
||||||
'routes/**/*.php' => [$testPath],
|
|
||||||
|
|
||||||
'bootstrap/app.php' => [$testPath],
|
|
||||||
'bootstrap/providers.php' => [$testPath],
|
|
||||||
|
|
||||||
'database/migrations/**/*.php' => [$testPath],
|
'database/migrations/**/*.php' => [$testPath],
|
||||||
|
|
||||||
'database/seeders/**/*.php' => [$testPath],
|
|
||||||
|
|
||||||
'database/factories/**/*.php' => [$testPath],
|
|
||||||
|
|
||||||
'storage/fixtures/**/*' => [$testPath],
|
'storage/fixtures/**/*' => [$testPath],
|
||||||
|
|
||||||
'app/**/*.tpl' => [$testPath],
|
'app/** !*.php' => [$testPath],
|
||||||
'app/**/*.stub' => [$testPath],
|
|
||||||
'app/**/*.json' => [$testPath],
|
|
||||||
'app/**/*.yaml' => [$testPath],
|
|
||||||
'app/**/*.yml' => [$testPath],
|
|
||||||
'app/**/*.txt' => [$testPath],
|
|
||||||
|
|
||||||
'resources/views/**/*.blade.php' => [$testPath],
|
'resources/views/**' => [$testPath],
|
||||||
'resources/views/**/*.css' => [$testPath],
|
|
||||||
'resources/views/email/**/*.blade.php' => [$testPath],
|
|
||||||
'resources/views/emails/**/*.blade.php' => [$testPath],
|
|
||||||
|
|
||||||
'lang/**/*.php' => [$testPath],
|
'lang/**' => [$testPath],
|
||||||
'lang/**/*.json' => [$testPath],
|
'resources/lang/**' => [$testPath],
|
||||||
'resources/lang/**/*.php' => [$testPath],
|
|
||||||
'resources/lang/**/*.json' => [$testPath],
|
|
||||||
|
|
||||||
'vite.config.js' => [$testPath],
|
'vite.config.* !*.php' => [$testPath],
|
||||||
'vite.config.ts' => [$testPath],
|
'webpack.mix.* !*.php' => [$testPath],
|
||||||
'webpack.mix.js' => [$testPath],
|
'tailwind.config.* !*.php' => [$testPath],
|
||||||
'tailwind.config.js' => [$testPath],
|
'postcss.config.* !*.php' => [$testPath],
|
||||||
'tailwind.config.ts' => [$testPath],
|
|
||||||
'postcss.config.js' => [$testPath],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -25,11 +27,7 @@ final readonly class Php implements WatchDefault
|
|||||||
'docker-compose.yml' => [$testPath],
|
'docker-compose.yml' => [$testPath],
|
||||||
'docker-compose.yaml' => [$testPath],
|
'docker-compose.yaml' => [$testPath],
|
||||||
|
|
||||||
'phpunit.xml.dist' => [$testPath],
|
'phpunit.xml*' => [$testPath],
|
||||||
|
|
||||||
$testPath.'/Pest.php' => [$testPath],
|
|
||||||
|
|
||||||
$testPath.'/Datasets/**/*.php' => [$testPath],
|
|
||||||
|
|
||||||
$testPath.'/Fixtures/**/*' => [$testPath],
|
$testPath.'/Fixtures/**/*' => [$testPath],
|
||||||
$testPath.'/**/Fixtures/**/*' => [$testPath],
|
$testPath.'/**/Fixtures/**/*' => [$testPath],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@@ -20,43 +21,22 @@ final readonly class Symfony implements WatchDefault
|
|||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'config/*.yaml' => [$testPath],
|
'config/** !*.php' => [$testPath],
|
||||||
'config/*.yml' => [$testPath],
|
'config/routes/** !*.php' => [$testPath],
|
||||||
'config/*.php' => [$testPath],
|
|
||||||
'config/*.xml' => [$testPath],
|
|
||||||
'config/**/*.yaml' => [$testPath],
|
|
||||||
'config/**/*.yml' => [$testPath],
|
|
||||||
'config/**/*.php' => [$testPath],
|
|
||||||
'config/**/*.xml' => [$testPath],
|
|
||||||
|
|
||||||
'config/routes/*.yaml' => [$testPath],
|
|
||||||
'config/routes/*.php' => [$testPath],
|
|
||||||
'config/routes/*.xml' => [$testPath],
|
|
||||||
'config/routes/**/*.yaml' => [$testPath],
|
|
||||||
|
|
||||||
'src/Kernel.php' => [$testPath],
|
|
||||||
|
|
||||||
'migrations/**/*.php' => [$testPath],
|
'migrations/**/*.php' => [$testPath],
|
||||||
'src/Migrations/**/*.php' => [$testPath],
|
'src/Migrations/**/*.php' => [$testPath],
|
||||||
|
|
||||||
'templates/**/*.html.twig' => [$testPath],
|
'templates/** !*.php' => [$testPath],
|
||||||
'templates/**/*.twig' => [$testPath],
|
|
||||||
|
|
||||||
'translations/**/*.yaml' => [$testPath],
|
'translations/** !*.php' => [$testPath],
|
||||||
'translations/**/*.yml' => [$testPath],
|
|
||||||
'translations/**/*.xlf' => [$testPath],
|
|
||||||
'translations/**/*.xliff' => [$testPath],
|
|
||||||
|
|
||||||
'config/doctrine/**/*.xml' => [$testPath],
|
'config/doctrine/**/*.xml' => [$testPath],
|
||||||
'config/doctrine/**/*.yaml' => [$testPath],
|
'config/doctrine/**/*.yaml' => [$testPath],
|
||||||
|
|
||||||
'webpack.config.js' => [$testPath],
|
'webpack.config.js' => [$testPath],
|
||||||
'importmap.php' => [$testPath],
|
'importmap.php' => [$testPath],
|
||||||
'assets/**/*.js' => [$testPath],
|
'assets/** !*.php' => [$testPath],
|
||||||
'assets/**/*.ts' => [$testPath],
|
|
||||||
'assets/**/*.vue' => [$testPath],
|
|
||||||
'assets/**/*.css' => [$testPath],
|
|
||||||
'assets/**/*.scss' => [$testPath],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,17 +24,26 @@ final class WatchPatterns
|
|||||||
WatchDefaults\Browser::class,
|
WatchDefaults\Browser::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files
|
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
|
||||||
*/
|
*/
|
||||||
private array $patterns = [];
|
private array $patterns = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
|
||||||
|
*/
|
||||||
|
private array $parsed = [];
|
||||||
|
|
||||||
private bool $enabled = false;
|
private bool $enabled = false;
|
||||||
|
|
||||||
private bool $locally = false;
|
private bool $locally = false;
|
||||||
|
|
||||||
private bool $filtered = false;
|
private bool $filtered = false;
|
||||||
|
|
||||||
|
private bool $baselined = false;
|
||||||
|
|
||||||
public function useDefaults(string $projectRoot): void
|
public function useDefaults(string $projectRoot): void
|
||||||
{
|
{
|
||||||
$testPath = TestSuite::getInstance()->testPath;
|
$testPath = TestSuite::getInstance()->testPath;
|
||||||
@@ -46,22 +55,22 @@ final class WatchPatterns
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
|
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
|
||||||
$this->patterns[$glob] = array_values(array_unique(
|
$this->patterns[$key] = array_values(array_unique(
|
||||||
array_merge($this->patterns[$glob] ?? [], $dirs),
|
array_merge($this->patterns[$key] ?? [], $dirs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $patterns glob → project-relative test dir/file
|
* @param array<string, string> $patterns pattern key → project-relative test dir/file
|
||||||
*/
|
*/
|
||||||
public function add(array $patterns): void
|
public function add(array $patterns): void
|
||||||
{
|
{
|
||||||
foreach ($patterns as $glob => $dir) {
|
foreach ($patterns as $key => $dir) {
|
||||||
$this->patterns[$glob] = array_values(array_unique(
|
$this->patterns[$key] = array_values(array_unique(
|
||||||
array_merge($this->patterns[$glob] ?? [], [$dir]),
|
array_merge($this->patterns[$key] ?? [], [$dir]),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,14 +89,16 @@ final class WatchPatterns
|
|||||||
$matched = [];
|
$matched = [];
|
||||||
|
|
||||||
foreach ($changedFiles as $file) {
|
foreach ($changedFiles as $file) {
|
||||||
foreach ($this->patterns as $glob => $dirs) {
|
foreach ($this->patterns as $key => $dirs) {
|
||||||
if ($this->globMatches($glob, $file)) {
|
if (! $this->keyMatches($key, $file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($dirs as $dir) {
|
foreach ($dirs as $dir) {
|
||||||
$matched[$dir] = true;
|
$matched[$dir] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys($matched);
|
return array_keys($matched);
|
||||||
}
|
}
|
||||||
@@ -156,12 +167,114 @@ final class WatchPatterns
|
|||||||
return $this->filtered;
|
return $this->filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function markBaselined(): void
|
||||||
|
{
|
||||||
|
$this->baselined = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBaselined(): bool
|
||||||
|
{
|
||||||
|
return $this->baselined;
|
||||||
|
}
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->patterns = [];
|
$this->patterns = [];
|
||||||
|
$this->parsed = [];
|
||||||
$this->enabled = false;
|
$this->enabled = false;
|
||||||
$this->locally = false;
|
$this->locally = false;
|
||||||
$this->filtered = false;
|
$this->filtered = false;
|
||||||
|
$this->baselined = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keyMatches(string $key, string $file): bool
|
||||||
|
{
|
||||||
|
$rule = $this->parse($key);
|
||||||
|
|
||||||
|
if (! $this->globMatches($rule['include'], $file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = str_replace('\\', '/', $file);
|
||||||
|
|
||||||
|
if ($this->touchesVcs($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rule['excludes'] as $exclude) {
|
||||||
|
if ($this->excludeMatches($exclude, $file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
|
||||||
|
*/
|
||||||
|
private function parse(string $key): array
|
||||||
|
{
|
||||||
|
if (isset($this->parsed[$key])) {
|
||||||
|
return $this->parsed[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = preg_split('/\s+/', trim($key)) ?: [];
|
||||||
|
|
||||||
|
$include = '';
|
||||||
|
$excludes = [];
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if ($token === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token[0] === '!') {
|
||||||
|
$excludes[] = substr($token, 1);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($include === '') {
|
||||||
|
$include = $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parsed[$key] = [
|
||||||
|
'include' => $include,
|
||||||
|
'excludes' => $excludes,
|
||||||
|
'allowDotfiles' => $this->patternTargetsDotfiles($include),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function patternTargetsDotfiles(string $pattern): bool
|
||||||
|
{
|
||||||
|
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function touchesVcs(string $file): bool
|
||||||
|
{
|
||||||
|
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function touchesDotfile(string $file): bool
|
||||||
|
{
|
||||||
|
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function excludeMatches(string $exclude, string $file): bool
|
||||||
|
{
|
||||||
|
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
|
||||||
|
|
||||||
|
if ($this->globMatches($pattern, $file)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->globMatches($exclude, basename($file));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function globMatches(string $pattern, string $file): bool
|
private function globMatches(string $pattern, string $file): bool
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ final class PcovRestarter implements Restarter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (getenv(self::ENV_RESTARTED) === '1') {
|
if (getenv(self::ENV_RESTARTED) === '1') {
|
||||||
|
putenv(self::ENV_RESTARTED);
|
||||||
|
unset($_ENV[self::ENV_RESTARTED]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ final class XdebugRestarter implements Restarter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(new XdebugHandler('pest'))->check();
|
new XdebugHandler('pest')->check();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function xdebugIsCoverageOnly(): bool
|
private function xdebugIsCoverageOnly(): bool
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
final class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Indicates if the TeamCity logger has already been registered.
|
||||||
|
*/
|
||||||
|
private static bool $registered = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Configured Subscriber instance.
|
* Creates a new Configured Subscriber instance.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private InputInterface $input,
|
private readonly InputInterface $input,
|
||||||
private OutputInterface $output,
|
private readonly OutputInterface $output,
|
||||||
private TestSuite $testSuite,
|
private readonly TestSuite $testSuite,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,10 +36,16 @@ final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
|||||||
*/
|
*/
|
||||||
public function notify(Configured $event): void
|
public function notify(Configured $event): void
|
||||||
{
|
{
|
||||||
|
if (self::$registered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->input->hasParameterOption('--teamcity')) {
|
if (! $this->input->hasParameterOption('--teamcity')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self::$registered = true;
|
||||||
|
|
||||||
$flowId = getenv('FLOW_ID');
|
$flowId = getenv('FLOW_ID');
|
||||||
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
|
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use PHPUnit\Event\Test\FinishedSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
|
final readonly class EnsureTiaEnds implements FinishedSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(private Recorder $recorder) {}
|
public function __construct(private Recorder $recorder) {}
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Subscribers;
|
||||||
|
|
||||||
|
use Pest\Exceptions\TiaRequiresPestTests;
|
||||||
|
use Pest\Panic;
|
||||||
|
use Pest\Plugins\Tia\Recorder;
|
||||||
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
|
use PHPUnit\Event\Test\Prepared;
|
||||||
|
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscriber
|
||||||
|
{
|
||||||
|
public function __construct(private Recorder $recorder) {}
|
||||||
|
|
||||||
|
public function notify(Prepared $event): void
|
||||||
|
{
|
||||||
|
if (! $this->recorder->isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$test = $event->test();
|
||||||
|
|
||||||
|
if (! $test instanceof TestMethod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$className = $test->className();
|
||||||
|
|
||||||
|
if (! class_exists($className, false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists($className, '__initializeTestCase')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Panic::with(new TiaRequiresPestTests($className, $test->file()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,17 +6,17 @@ namespace Pest\Subscribers;
|
|||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
use Pest\Plugins\Tia\ResultCollector;
|
||||||
use PHPUnit\Event\Code\TestMethod;
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
use PHPUnit\Event\Test\Prepared;
|
use PHPUnit\Event\Test\PreparationStarted;
|
||||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
use PHPUnit\Event\Test\PreparationStartedSubscriber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
|
final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(private ResultCollector $collector) {}
|
public function __construct(private ResultCollector $collector) {}
|
||||||
|
|
||||||
public function notify(Prepared $event): void
|
public function notify(PreparationStarted $event): void
|
||||||
{
|
{
|
||||||
$test = $event->test();
|
$test = $event->test();
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ use PHPUnit\Event\Test\PreparedSubscriber;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
|
final readonly class EnsureTiaStarts implements PreparedSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(private Recorder $recorder) {}
|
public function __construct(private Recorder $recorder) {}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
|
|||||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||||
use SebastianBergmann\CodeCoverage\Node\File;
|
use SebastianBergmann\CodeCoverage\Node\File;
|
||||||
|
use SebastianBergmann\CodeCoverage\Report\Facade;
|
||||||
use SebastianBergmann\Environment\Runtime;
|
use SebastianBergmann\Environment\Runtime;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
@@ -89,20 +90,24 @@ final class Coverage
|
|||||||
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
|
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TIA's marker is present, this run executed only the affected
|
|
||||||
// tests. Merge their fresh coverage slice into the cached full-run
|
|
||||||
// snapshot (stored by the previous `--tia --coverage` pass) so the
|
|
||||||
// report reflects the entire suite, not just what re-ran.
|
|
||||||
CoverageMerger::applyIfMarked($reportPath);
|
CoverageMerger::applyIfMarked($reportPath);
|
||||||
|
|
||||||
/** @var CodeCoverage $codeCoverage */
|
/** @var CodeCoverage $codeCoverage */
|
||||||
$codeCoverage = require $reportPath;
|
$codeCoverage = require $reportPath;
|
||||||
unlink($reportPath);
|
unlink($reportPath);
|
||||||
|
|
||||||
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
|
// @phpstan-ignore-next-line
|
||||||
|
if (is_array($codeCoverage)) {
|
||||||
|
$facade = Facade::fromSerializedData($codeCoverage);
|
||||||
|
|
||||||
|
/** @var Directory<File|Directory> $report */
|
||||||
|
$report = (fn (): Directory => $this->report)->call($facade);
|
||||||
|
} else {
|
||||||
/** @var Directory<File|Directory> $report */
|
/** @var Directory<File|Directory> $report */
|
||||||
$report = $codeCoverage->getReport();
|
$report = $codeCoverage->getReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCoverage = $report->percentageOfExecutedLines();
|
||||||
|
|
||||||
foreach ($report->getIterator() as $file) {
|
foreach ($report->getIterator() as $file) {
|
||||||
if (! $file instanceof File) {
|
if (! $file instanceof File) {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Support;
|
|
||||||
|
|
||||||
use Fidry\CpuCoreCounter\CpuCoreCounter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class Cpu
|
|
||||||
{
|
|
||||||
public static function cores(int $fallback = 4): int
|
|
||||||
{
|
|
||||||
return (new CpuCoreCounter)->getCountWithFallback($fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,4 +86,17 @@ final readonly class Exporter
|
|||||||
|
|
||||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
|
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports a value into a full single-line string without truncation.
|
||||||
|
*/
|
||||||
|
public function export(mixed $value): string
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'#\\\n\s*#' => '',
|
||||||
|
'# Object \(\.{3}\)#' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class HigherOrderMessage
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->hasHigherOrderCallable()) {
|
if ($this->hasHigherOrderCallable()) {
|
||||||
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
|
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
|
|||||||
*/
|
*/
|
||||||
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
|
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
|
||||||
{
|
{
|
||||||
$this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition);
|
$this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments)->when($condition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ final class HigherOrderTapProxy
|
|||||||
return $this->target->{$property};
|
return $this->target->{$property};
|
||||||
}
|
}
|
||||||
|
|
||||||
$className = (new ReflectionClass($this->target))->getName();
|
$className = new ReflectionClass($this->target)->getName();
|
||||||
|
|
||||||
if (str_starts_with($className, 'P\\')) {
|
if (str_starts_with($className, 'P\\')) {
|
||||||
$className = substr($className, 2);
|
$className = substr($className, 2);
|
||||||
@@ -60,7 +60,7 @@ final class HigherOrderTapProxy
|
|||||||
$filename = Backtrace::file();
|
$filename = Backtrace::file();
|
||||||
$line = Backtrace::line();
|
$line = Backtrace::line();
|
||||||
|
|
||||||
return (new HigherOrderMessage($filename, $line, $methodName, $arguments))
|
return new HigherOrderMessage($filename, $line, $methodName, $arguments)
|
||||||
->call($this->target);
|
->call($this->target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ final class Reflection
|
|||||||
*/
|
*/
|
||||||
public static function getFunctionArguments(Closure $function): array
|
public static function getFunctionArguments(Closure $function): array
|
||||||
{
|
{
|
||||||
$parameters = (new ReflectionFunction($function))->getParameters();
|
$parameters = new ReflectionFunction($function)->getParameters();
|
||||||
$arguments = [];
|
$arguments = [];
|
||||||
|
|
||||||
foreach ($parameters as $parameter) {
|
foreach ($parameters as $parameter) {
|
||||||
@@ -207,7 +207,7 @@ final class Reflection
|
|||||||
|
|
||||||
public static function getFunctionVariable(Closure $function, string $key): mixed
|
public static function getFunctionVariable(Closure $function, string $key): mixed
|
||||||
{
|
{
|
||||||
return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null;
|
return new ReflectionFunction($function)->getStaticVariables()[$key] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ use NunoMaduro\Collision\Exceptions\TestOutcome;
|
|||||||
use PHPUnit\Event\Code\TestDoxBuilder;
|
use PHPUnit\Event\Code\TestDoxBuilder;
|
||||||
use PHPUnit\Event\Code\TestMethod;
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
use PHPUnit\Event\Code\ThrowableBuilder;
|
use PHPUnit\Event\Code\ThrowableBuilder;
|
||||||
|
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||||
|
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||||
|
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||||
|
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\Errored;
|
use PHPUnit\Event\Test\Errored;
|
||||||
use PHPUnit\Event\Test\Failed;
|
use PHPUnit\Event\Test\Failed;
|
||||||
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
|
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
|
||||||
@@ -35,8 +39,7 @@ final class StateGenerator
|
|||||||
$testResultEvent->throwable()
|
$testResultEvent->throwable()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
// @phpstan-ignore-next-line
|
$this->addClassLevelEvent($state, $testResultEvent);
|
||||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +51,7 @@ final class StateGenerator
|
|||||||
$testResultEvent->throwable()
|
$testResultEvent->throwable()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
// @phpstan-ignore-next-line
|
$this->addClassLevelEvent($state, $testResultEvent);
|
||||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,36 @@ final class StateGenerator
|
|||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given class-level "hook" failure to the state. Collision's
|
||||||
|
* `fromBeforeFirstTestMethodErrored` only accepts `BeforeFirstTestMethodErrored`
|
||||||
|
* events, so the remaining class-level events get a synthesized test method.
|
||||||
|
*/
|
||||||
|
private function addClassLevelEvent(State $state, AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed $event): void
|
||||||
|
{
|
||||||
|
if ($event instanceof BeforeFirstTestMethodErrored) {
|
||||||
|
$state->add(TestResult::fromBeforeFirstTestMethodErrored($event));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$methodName = $event instanceof BeforeFirstTestMethodFailed ? 'beforeAll' : 'afterAll';
|
||||||
|
|
||||||
|
$state->add(TestResult::fromPestParallelTestCase(
|
||||||
|
new TestMethod(
|
||||||
|
$event->testClassName(), // @phpstan-ignore-line
|
||||||
|
$methodName,
|
||||||
|
'', // @phpstan-ignore-line
|
||||||
|
1,
|
||||||
|
TestDoxBuilder::fromClassNameAndMethodName($event->testClassName(), $methodName), // @phpstan-ignore-line
|
||||||
|
MetadataCollection::fromArray([]),
|
||||||
|
TestDataCollection::fromArray([])
|
||||||
|
),
|
||||||
|
TestResult::FAIL,
|
||||||
|
$event->throwable()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
|
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ final class Str
|
|||||||
return preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0;
|
return preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a given value is a valid ULID.
|
||||||
|
*/
|
||||||
|
public static function isUlid(string $value): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', $value) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a describe block as `$describeDescription` → `$testDescription` format.
|
* Creates a describe block as `$describeDescription` → `$testDescription` format.
|
||||||
*
|
*
|
||||||
@@ -110,6 +118,14 @@ final class Str
|
|||||||
return sprintf(str_repeat('`%s` → ', count($describeDescriptions)).'%s', ...$descriptionComponents);
|
return sprintf(str_repeat('`%s` → ', count($describeDescriptions)).'%s', ...$descriptionComponents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a given value is a valid email address.
|
||||||
|
*/
|
||||||
|
public static function isEmail(string $value): bool
|
||||||
|
{
|
||||||
|
return (bool) filter_var($value, FILTER_VALIDATE_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a given value is a valid URL.
|
* Determine if a given value is a valid URL.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,18 +6,18 @@
|
|||||||
FAILED Tests\Fixtures\CollisionTest > error Exception
|
FAILED Tests\Fixtures\CollisionTest > error Exception
|
||||||
error
|
error
|
||||||
|
|
||||||
at tests/Fixtures/CollisionTest.php:4
|
at tests/Fixtures/CollisionTest.php:6
|
||||||
1▕ <?php
|
|
||||||
2▕
|
2▕
|
||||||
3▕ test('error', function () {
|
3▕ declare(strict_types=1);
|
||||||
➜ 4▕ throw new Exception('error');
|
4▕
|
||||||
5▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
5▕ test('error', function (): void {
|
||||||
6▕
|
➜ 6▕ throw new Exception('error');
|
||||||
7▕ test('success', function () {
|
7▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
||||||
8▕ expect(true)->toBeTrue();
|
8▕
|
||||||
9▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
9▕ test('success', function (): void {
|
||||||
|
10▕ expect(true)->toBeTrue();
|
||||||
|
|
||||||
1 tests/Fixtures/CollisionTest.php:4
|
1 tests/Fixtures/CollisionTest.php:6
|
||||||
|
|
||||||
|
|
||||||
Tests: 1 failed, 1 passed (1 assertions)
|
Tests: 1 failed, 1 passed (1 assertions)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
Pest Testing Framework 4.6.3.
|
Pest Testing Framework 5.0.0-beta.1.
|
||||||
|
|
||||||
USAGE: pest <file> [options]
|
USAGE: pest <file> [options]
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
--cache-directory [dir] ............................ Specify cache directory
|
--cache-directory [dir] ............................ Specify cache directory
|
||||||
--generate-configuration Generate configuration file with suggested settings
|
--generate-configuration Generate configuration file with suggested settings
|
||||||
--migrate-configuration ....... Migrate configuration file to current format
|
--migrate-configuration ....... Migrate configuration file to current format
|
||||||
|
--validate-configuration ................... Validate XML configuration file
|
||||||
--generate-baseline [file] .................... Generate baseline for issues
|
--generate-baseline [file] .................... Generate baseline for issues
|
||||||
--use-baseline [file] ........................ Use baseline to ignore issues
|
--use-baseline [file] ........................ Use baseline to ignore issues
|
||||||
--ignore-baseline ..................... Do not use baseline to ignore issues
|
--ignore-baseline ..................... Do not use baseline to ignore issues
|
||||||
@@ -40,11 +41,15 @@
|
|||||||
--uses [name] ..................... Only run tests that intend to use [name]
|
--uses [name] ..................... Only run tests that intend to use [name]
|
||||||
--requires-php-extension [name] Only run tests that require PHP extension [name]
|
--requires-php-extension [name] Only run tests that require PHP extension [name]
|
||||||
--list-test-files ................................ List available test files
|
--list-test-files ................................ List available test files
|
||||||
|
--list-test-ids ........................... List available tests as test IDs
|
||||||
--list-tests .......................................... List available tests
|
--list-tests .......................................... List available tests
|
||||||
--list-tests-xml [file] ................. List available tests in XML format
|
--list-tests-xml [file] ................. List available tests in XML format
|
||||||
--filter [pattern] ............................... Filter which tests to run
|
--filter [pattern] ............................... Filter which tests to run
|
||||||
--exclude-filter [pattern] .. Exclude tests for the specified filter pattern
|
--exclude-filter [pattern] .. Exclude tests for the specified filter pattern
|
||||||
--test-suffix [suffixes] Only search for test in files with specified suffix(es). Default: Test.php,.phpt
|
--test-suffix [suffixes] Only search for test in files with specified suffix(es). Default: Test.php,.phpt
|
||||||
|
--test-files-file [file] Only run test files listed in file (one file by line)
|
||||||
|
--run-test-id [test-id] Only run the test identified by the specified test ID
|
||||||
|
--test-id-filter-file [file] Only run tests listed by test ID in file (one test ID per line)
|
||||||
|
|
||||||
EXECUTION OPTIONS:
|
EXECUTION OPTIONS:
|
||||||
--parallel ........................................... Run tests in parallel
|
--parallel ........................................... Run tests in parallel
|
||||||
@@ -53,20 +58,21 @@
|
|||||||
--globals-backup ................. Backup and restore $GLOBALS for each test
|
--globals-backup ................. Backup and restore $GLOBALS for each test
|
||||||
--static-backup ......... Backup and restore static properties for each test
|
--static-backup ......... Backup and restore static properties for each test
|
||||||
--strict-coverage ................... Be strict about code coverage metadata
|
--strict-coverage ................... Be strict about code coverage metadata
|
||||||
|
--require-coverage-contribution Be strict about tests that do not contribute to code coverage
|
||||||
--strict-global-state .............. Be strict about changes to global state
|
--strict-global-state .............. Be strict about changes to global state
|
||||||
--disallow-test-output ................. Be strict about output during tests
|
--disallow-test-output ................. Be strict about output during tests
|
||||||
--enforce-time-limit ................. Enforce time limit based on test size
|
--enforce-time-limit ................. Enforce time limit based on test size
|
||||||
--default-time-limit [sec] Timeout in seconds for tests that have no declared size
|
--default-time-limit [sec] Timeout in seconds for tests that have no declared size
|
||||||
--do-not-report-useless-tests Do not report tests that do not test anything
|
--do-not-report-useless-tests Do not report tests that do not test anything
|
||||||
--stop-on-defect ... Stop after first error, failure, warning, or risky test
|
--stop-on-defect[=[n]] Stop after first (or n-th) error, failure, warning, or risky test
|
||||||
--stop-on-error ..................................... Stop after first error
|
--stop-on-error[=[n]] ..................... Stop after first (or n-th) error
|
||||||
--stop-on-failure ................................. Stop after first failure
|
--stop-on-failure[=[n]] ................. Stop after first (or n-th) failure
|
||||||
--stop-on-warning ................................. Stop after first warning
|
--stop-on-warning[=[n]] ................. Stop after first (or n-th) warning
|
||||||
--stop-on-risky ................................ Stop after first risky test
|
--stop-on-risky[=[n]] ................ Stop after first (or n-th) risky test
|
||||||
--stop-on-deprecation ... Stop after first test that triggered a deprecation
|
--stop-on-deprecation[=[n]] Stop after first (or n-th) test that triggered a deprecation
|
||||||
--stop-on-notice ............. Stop after first test that triggered a notice
|
--stop-on-notice[=[n]] Stop after first (or n-th) test that triggered a notice
|
||||||
--stop-on-skipped ............................ Stop after first skipped test
|
--stop-on-skipped[=[n]] ............ Stop after first (or n-th) skipped test
|
||||||
--stop-on-incomplete ...................... Stop after first incomplete test
|
--stop-on-incomplete[=[n]] ...... Stop after first (or n-th) incomplete test
|
||||||
--fail-on-empty-test-suite Signal failure using shell exit code when no tests were run
|
--fail-on-empty-test-suite Signal failure using shell exit code when no tests were run
|
||||||
--fail-on-warning Signal failure using shell exit code when a warning was triggered
|
--fail-on-warning Signal failure using shell exit code when a warning was triggered
|
||||||
--fail-on-risky Signal failure using shell exit code when a test was considered risky
|
--fail-on-risky Signal failure using shell exit code when a test was considered risky
|
||||||
@@ -90,7 +96,7 @@
|
|||||||
--do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete
|
--do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete
|
||||||
--cache-result ............................ Write test results to cache file
|
--cache-result ............................ Write test results to cache file
|
||||||
--do-not-cache-result .............. Do not write test results to cache file
|
--do-not-cache-result .............. Do not write test results to cache file
|
||||||
--order-by [order] Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size
|
--order-by [order] Run tests in order: default|defects|depends|duration-ascending|duration-descending|no-depends|random|reverse|size-ascending|size-descending
|
||||||
--resolve-dependencies ...................... Alias for "--order-by depends"
|
--resolve-dependencies ...................... Alias for "--order-by depends"
|
||||||
--ignore-dependencies .................... Alias for "--order-by no-depends"
|
--ignore-dependencies .................... Alias for "--order-by no-depends"
|
||||||
--random-order ............................... Alias for "--order-by random"
|
--random-order ............................... Alias for "--order-by random"
|
||||||
@@ -101,6 +107,7 @@
|
|||||||
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
|
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
|
||||||
--columns [n] ................. Number of columns to use for progress output
|
--columns [n] ................. Number of columns to use for progress output
|
||||||
--columns max ............ Use maximum number of columns for progress output
|
--columns max ............ Use maximum number of columns for progress output
|
||||||
|
--diff-context [n] Number of context lines shown around changes in diffs (default: 3)
|
||||||
--stderr ................................. Write to STDERR instead of STDOUT
|
--stderr ................................. Write to STDERR instead of STDOUT
|
||||||
--no-progress .................... Disable output of test execution progress
|
--no-progress .................... Disable output of test execution progress
|
||||||
--no-results ................................ Disable output of test results
|
--no-results ................................ Disable output of test results
|
||||||
@@ -115,6 +122,7 @@
|
|||||||
--display-warnings ......... Display details for warnings triggered by tests
|
--display-warnings ......... Display details for warnings triggered by tests
|
||||||
--display-all-issues ..... Display details for all issues that are triggered
|
--display-all-issues ..... Display details for all issues that are triggered
|
||||||
--reverse-list .............................. Print defects in reverse order
|
--reverse-list .............................. Print defects in reverse order
|
||||||
|
--compact ... Replace default progress and result output with compact format
|
||||||
--teamcity . Replace default progress and result output with TeamCity format
|
--teamcity . Replace default progress and result output with TeamCity format
|
||||||
--testdox ................ Replace default result output with TestDox format
|
--testdox ................ Replace default result output with TestDox format
|
||||||
--testdox-summary Repeat TestDox output for tests with errors, failures, or issues
|
--testdox-summary Repeat TestDox output for tests with errors, failures, or issues
|
||||||
@@ -125,12 +133,12 @@
|
|||||||
LOGGING OPTIONS:
|
LOGGING OPTIONS:
|
||||||
--log-junit [file] .......... Write test results in JUnit XML format to file
|
--log-junit [file] .......... Write test results in JUnit XML format to file
|
||||||
--log-otr [file] Write test results in Open Test Reporting XML format to file
|
--log-otr [file] Write test results in Open Test Reporting XML format to file
|
||||||
--include-git-information Include Git information in Open Test Reporting XML logfile
|
|
||||||
--log-teamcity [file] ........ Write test results in TeamCity format to file
|
--log-teamcity [file] ........ Write test results in TeamCity format to file
|
||||||
--testdox-html [file] .. Write test results in TestDox format (HTML) to file
|
--testdox-html [file] .. Write test results in TestDox format (HTML) to file
|
||||||
--testdox-text [file] Write test results in TestDox format (plain text) to file
|
--testdox-text [file] Write test results in TestDox format (plain text) to file
|
||||||
--log-events-text [file] ............... Stream events as plain text to file
|
--log-events-text [file] ............... Stream events as plain text to file
|
||||||
--log-events-verbose-text [file] Stream events as plain text with extended information to file
|
--log-events-verbose-text [file] Stream events as plain text with extended information to file
|
||||||
|
--include-git-information ..... Include Git information in supported formats
|
||||||
--no-logging ....... Ignore logging configured in the XML configuration file
|
--no-logging ....... Ignore logging configured in the XML configuration file
|
||||||
|
|
||||||
CODE COVERAGE OPTIONS:
|
CODE COVERAGE OPTIONS:
|
||||||
@@ -151,8 +159,10 @@
|
|||||||
--exclude-source-from-xml-coverage Exclude [source] element from code coverage report in XML format
|
--exclude-source-from-xml-coverage Exclude [source] element from code coverage report in XML format
|
||||||
--warm-coverage-cache ........................... Warm static analysis cache
|
--warm-coverage-cache ........................... Warm static analysis cache
|
||||||
--coverage-filter [dir] ........... Include [dir] in code coverage reporting
|
--coverage-filter [dir] ........... Include [dir] in code coverage reporting
|
||||||
--path-coverage .......... Report path coverage in addition to line coverage
|
--branch-coverage ...... Report branch coverage in addition to line coverage
|
||||||
|
--path-coverage ... Report path coverage in addition to line/branch coverage
|
||||||
--disable-coverage-ignore ...... Disable metadata for ignoring code coverage
|
--disable-coverage-ignore ...... Disable metadata for ignoring code coverage
|
||||||
|
--disable-coverage-targeting .. Disable metadata for code coverage targeting
|
||||||
--no-coverage Ignore code coverage reporting configured in the XML configuration file
|
--no-coverage Ignore code coverage reporting configured in the XML configuration file
|
||||||
|
|
||||||
AI OPTIONS:
|
AI OPTIONS:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
|
|
||||||
Pest Testing Framework 4.6.3.
|
Pest Testing Framework 5.0.0-beta.1.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
|
✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
|
||||||
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
|
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
|
||||||
✓ globals
|
✓ globals
|
||||||
✓ contracts
|
|
||||||
|
|
||||||
PASS Tests\Environments\Windows
|
PASS Tests\Environments\Windows
|
||||||
✓ global functions are loaded
|
✓ global functions are loaded
|
||||||
@@ -137,6 +136,11 @@
|
|||||||
✓ describe()->with() preserves depends → first with (9)
|
✓ describe()->with() preserves depends → first with (9)
|
||||||
✓ describe()->with() preserves depends → second with (9)
|
✓ describe()->with() preserves depends → second with (9)
|
||||||
|
|
||||||
|
PASS Tests\Features\DatasetProviderErrors
|
||||||
|
✓ reports missing datasets as errors for a single file run
|
||||||
|
✓ reports missing datasets as errors alongside passing tests
|
||||||
|
✓ reports dataset closure exceptions as errors
|
||||||
|
|
||||||
PASS Tests\Features\DatasetsTests - 1 todo
|
PASS Tests\Features\DatasetsTests - 1 todo
|
||||||
✓ it throws exception if dataset does not exist
|
✓ it throws exception if dataset does not exist
|
||||||
✓ it throws exception if dataset already exist
|
✓ it throws exception if dataset already exist
|
||||||
@@ -294,8 +298,8 @@
|
|||||||
✓ it uses correct parent class
|
✓ it uses correct parent class
|
||||||
|
|
||||||
DEPR Tests\Features\Deprecated
|
DEPR Tests\Features\Deprecated
|
||||||
! deprecated → str_contains(): Passing null to parameter #1 ($haystack) of type string is deprecated // tests/Features/Deprecated.php:4
|
✓ deprecated
|
||||||
! user deprecated → Since foo 1.0: This is a deprecation description // tests/Features/Deprecated.php:10
|
! user deprecated → Since foo 1.0: This is a deprecation description // tests/Features/Deprecated.php:12
|
||||||
|
|
||||||
PASS Tests\Features\Describe - 5 todos
|
PASS Tests\Features\Describe - 5 todos
|
||||||
✓ before each
|
✓ before each
|
||||||
@@ -472,6 +476,16 @@
|
|||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeBase64
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid padding
|
||||||
|
✓ failures with padding only
|
||||||
|
✓ failures with malformed input
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeBetween
|
PASS Tests\Features\Expect\toBeBetween
|
||||||
@@ -518,6 +532,21 @@
|
|||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeDomain
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with leading dot
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeEmail
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ failures with default message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeEmpty
|
PASS Tests\Features\Expect\toBeEmpty
|
||||||
@@ -573,6 +602,24 @@
|
|||||||
✓ passes with strings
|
✓ passes with strings
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeHexadecimal
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid characters
|
||||||
|
✓ failures with zero-x prefix
|
||||||
|
✓ failures with empty string
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeHostname
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with trailing hyphen
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeIn
|
PASS Tests\Features\Expect\toBeIn
|
||||||
@@ -611,6 +658,13 @@
|
|||||||
✓ failure when the class is not invokable
|
✓ failure when the class is not invokable
|
||||||
✓ class is not invokable
|
✓ class is not invokable
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeIpAddress
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeIterable
|
PASS Tests\Features\Expect\toBeIterable
|
||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
@@ -655,6 +709,13 @@
|
|||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeMacAddress
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeNAN
|
PASS Tests\Features\Expect\toBeNAN
|
||||||
@@ -753,6 +814,13 @@
|
|||||||
✓ passes as not truthy with ('0')
|
✓ passes as not truthy with ('0')
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeUlid
|
||||||
|
✓ failures with wrong type
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeUppercase
|
PASS Tests\Features\Expect\toBeUppercase
|
||||||
@@ -1261,8 +1329,8 @@
|
|||||||
// This is another runtime note
|
// This is another runtime note
|
||||||
|
|
||||||
NOTI Tests\Features\Notices
|
NOTI Tests\Features\Notices
|
||||||
! notice → This is a notice description // tests/Features/Notices.php:4
|
! notice → This is a notice description // tests/Features/Notices.php:6
|
||||||
! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:11
|
! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:13
|
||||||
|
|
||||||
PASS Tests\Features\Pr
|
PASS Tests\Features\Pr
|
||||||
✓ it may be associated with an pr #1, #2
|
✓ it may be associated with an pr #1, #2
|
||||||
@@ -1697,6 +1765,8 @@
|
|||||||
PASS Tests\Unit\Expectations\OppositeExpectation
|
PASS Tests\Unit\Expectations\OppositeExpectation
|
||||||
✓ it throw expectation failed exception with string argument
|
✓ it throw expectation failed exception with string argument
|
||||||
✓ it throw expectation failed exception with array argument
|
✓ it throw expectation failed exception with array argument
|
||||||
|
✓ it does not truncate long string arguments in error message
|
||||||
|
✓ it does not truncate custom error message when using not()
|
||||||
|
|
||||||
PASS Tests\Unit\Overrides\ThrowableBuilder
|
PASS Tests\Unit\Overrides\ThrowableBuilder
|
||||||
✓ collision editor can be added to the stack trace
|
✓ collision editor can be added to the stack trace
|
||||||
@@ -1708,6 +1778,8 @@
|
|||||||
✓ method hasArgument with ('someValue', true)
|
✓ method hasArgument with ('someValue', true)
|
||||||
✓ method hasArgument with ('--a', false)
|
✓ method hasArgument with ('--a', false)
|
||||||
✓ method hasArgument with ('--undefined-argument', false)
|
✓ method hasArgument with ('--undefined-argument', false)
|
||||||
|
✓ popArgument preserves duplicate values when removing a missing argument
|
||||||
|
✓ popArgument preserves duplicate values when removing an existing argument
|
||||||
|
|
||||||
PASS Tests\Unit\Plugins\Environment
|
PASS Tests\Unit\Plugins\Environment
|
||||||
✓ environment is set to CI when --ci option is used
|
✓ environment is set to CI when --ci option is used
|
||||||
@@ -1716,6 +1788,218 @@
|
|||||||
PASS Tests\Unit\Plugins\Retry
|
PASS Tests\Unit\Plugins\Retry
|
||||||
✓ it orders by defects and stop on defects if when --retry is used
|
✓ it orders by defects and stop on defects if when --retry is used
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Shard
|
||||||
|
✓ getShard → it parses valid shard format with ('1/2', 1, 2)
|
||||||
|
✓ getShard → it parses valid shard format with ('2/2', 2, 2)
|
||||||
|
✓ getShard → it parses valid shard format with ('1/4', 1, 4)
|
||||||
|
✓ getShard → it parses valid shard format with ('4/4', 4, 4)
|
||||||
|
✓ getShard → it parses valid shard format with ('1/10', 1, 10)
|
||||||
|
✓ getShard → it parses valid shard format with ('10/10', 10, 10)
|
||||||
|
✓ getShard → it parses valid shard format with ('5/100', 5, 100)
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'invalid'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1/'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '/2'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'a/b'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1.5/2'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '0/2'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '1/0'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '3/2'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '5/4'])
|
||||||
|
✓ buildFilterArgument → it generates compact filter for single test
|
||||||
|
✓ buildFilterArgument → it generates compact filter for multiple tests with common prefix
|
||||||
|
✓ buildFilterArgument → it generates compact filter for tests with different namespaces
|
||||||
|
✓ buildFilterArgument → it returns empty string for empty test list
|
||||||
|
✓ buildFilterArgument → it generates compact filter for deeply nested namespaces
|
||||||
|
✓ buildFilterArgument → it handles mix of nested and flat namespaces
|
||||||
|
✓ ensureFilterLengthIsSafe → it accepts filter within length limit
|
||||||
|
✓ ensureFilterLengthIsSafe → it throws exception when filter exceeds default limit
|
||||||
|
✓ ensureFilterLengthIsSafe → it respects custom limit from environment variable
|
||||||
|
✓ ensureFilterLengthIsSafe → it accepts filter within custom limit
|
||||||
|
✓ handleArguments → it returns original arguments when shard option is not present
|
||||||
|
✓ handleArguments → it removes parallel arguments from test discovery
|
||||||
|
✓ parseListTestsOutput → it parses Tests\ namespaced classes from --list-tests output
|
||||||
|
✓ parseListTestsOutput → it deduplicates repeated class names from multiple test methods
|
||||||
|
✓ parseListTestsOutput → it returns an empty list for output with no matching lines
|
||||||
|
✓ parseListTestsOutput → it parses non-Tests namespaced classes
|
||||||
|
✓ parseListTestsOutput → it parses unnamespaced top-level classes
|
||||||
|
✓ parseListTestsOutput → it strips the P\ Pest prefix but keeps the rest of the FQCN
|
||||||
|
✓ parseListTestsOutput → it ignores junk lines that lack the " - …::" framing
|
||||||
|
✓ buildListTestsCommand → it builds the list-tests command with the forwarded --test-directory
|
||||||
|
✓ buildListTestsCommand → it strips --parallel and -p when building the list-tests command
|
||||||
|
✓ buildListTestsCommand → it forwards --test-directory even when input arguments include one
|
||||||
|
✓ buildListTestsCommand → it strips --processes=N when building the list-tests command
|
||||||
|
✓ buildListTestsCommand → it strips --processes N (space-separated) when building the list-tests command
|
||||||
|
✓ addOutput → it displays shard information after test execution
|
||||||
|
✓ addOutput → it uses singular form for single test file
|
||||||
|
✓ addOutput → it returns original exit code when shard is not set
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\ContentHash
|
||||||
|
✓ of() → it returns false when file does not exist
|
||||||
|
✓ of() → it hashes an existing file
|
||||||
|
✓ PHP files → it produces the same hash regardless of whitespace differences
|
||||||
|
✓ PHP files → it ignores single-line comments
|
||||||
|
✓ PHP files → it ignores hash-style comments
|
||||||
|
✓ PHP files → it ignores multi-line comments
|
||||||
|
✓ PHP files → it ignores doc comments
|
||||||
|
✓ PHP files → it detects code changes
|
||||||
|
✓ PHP files → it preserves whitespace inside string literals
|
||||||
|
✓ PHP files → it treats variable renames as a change
|
||||||
|
✓ PHP files → it falls back to a raw hash for unparseable PHP
|
||||||
|
✓ PHP files → it is case-insensitive on the file extension
|
||||||
|
✓ Blade files → it strips blade comments
|
||||||
|
✓ Blade files → it strips multi-line blade comments
|
||||||
|
✓ Blade files → it collapses whitespace
|
||||||
|
✓ Blade files → it detects content changes
|
||||||
|
✓ Blade files → it keeps blade directives intact
|
||||||
|
✓ Blade files → it does not use the PHP tokenizer for blade files
|
||||||
|
✓ JavaScript-like files → it strips line comments
|
||||||
|
✓ JavaScript-like files → it strips block comments on their own lines
|
||||||
|
✓ JavaScript-like files → it collapses whitespace
|
||||||
|
✓ JavaScript-like files → it detects code changes
|
||||||
|
✓ JavaScript-like files → it does not strip inline trailing comments
|
||||||
|
✓ JavaScript-like files → it applies the same rules to .ts files
|
||||||
|
✓ JavaScript-like files → it applies the same rules to .tsx files
|
||||||
|
✓ JavaScript-like files → it applies the same rules to .jsx files
|
||||||
|
✓ JavaScript-like files → it applies the same rules to .vue files
|
||||||
|
✓ JavaScript-like files → it applies the same rules to .svelte files
|
||||||
|
✓ JavaScript-like files → it applies the same rules to .mjs, .cjs, and .mts files
|
||||||
|
✓ unknown extensions → it hashes the raw content for unknown extensions
|
||||||
|
✓ unknown extensions → it does not normalise whitespace for unknown extensions
|
||||||
|
✓ unknown extensions → it does not strip comments for unknown extensions
|
||||||
|
✓ unknown extensions → it hashes files with no extension as raw content
|
||||||
|
✓ output format → it returns a 32-character hex xxh128 hash
|
||||||
|
✓ output format → it returns a stable hash for empty content
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\FileState
|
||||||
|
✓ keysWithPrefix() → it lists keys matching the prefix
|
||||||
|
✓ keysWithPrefix() → it ignores in-flight temporary files from concurrent writes
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\Graph
|
||||||
|
✓ shouldRerunStatus() → it re-runs failures and errors, replays successes
|
||||||
|
✓ applyMigrationChanges() → it selects tests whose recorded tables intersect the changed migration
|
||||||
|
✓ applyMigrationChanges() → it falls back to watch patterns when no table usage was recorded at all
|
||||||
|
✓ rerun tracking → it reruns cached failures via their file
|
||||||
|
✓ rerun tracking → it flags cached failures whose file is unknown
|
||||||
|
✓ applyBladeStaticChanges() → it maps an anonymous index component to the views that render it
|
||||||
|
✓ applyBladeStaticChanges() → it falls back to watch patterns for components with no matched usage
|
||||||
|
✓ markKnownTestFiles() → it makes a test file with no edges known
|
||||||
|
✓ markKnownTestFiles() → it does not clobber edges of an already-known test file
|
||||||
|
✓ markKnownTestFiles() → it ignores paths outside the project root
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\IsEnabledForRun
|
||||||
|
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
|
||||||
|
✓ still detects --tia when an integer argument is present
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock
|
||||||
|
✓ it applies only to package-lock.json
|
||||||
|
✓ it returns null for contents that are not an npm lockfile
|
||||||
|
✓ it is stable when the directory-derived top-level name changes
|
||||||
|
✓ it ignores platform-specific native binaries added or dropped per OS
|
||||||
|
✓ it ignores libc-constrained variants
|
||||||
|
✓ it ignores runtime deps bundled beneath a platform-specific package
|
||||||
|
✓ it detects a real dependency version change
|
||||||
|
✓ it detects an added or removed non-platform dependency
|
||||||
|
✓ it is independent of package ordering
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\Recorder
|
||||||
|
✓ activateLinkTracking() → it tracks tables without a coverage driver
|
||||||
|
✓ activateLinkTracking() → it tracks inertia components without a coverage driver
|
||||||
|
✓ activateLinkTracking() → it tracks linked sources across consecutive tests
|
||||||
|
✓ activateLinkTracking() → it records nothing while inactive
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\TableExtractor
|
||||||
|
✓ fromSql() → it extracts tables from plain DML
|
||||||
|
✓ fromSql() → it extracts tables from joins
|
||||||
|
✓ fromSql() → it extracts tables from CTE queries
|
||||||
|
✓ fromSql() → it extracts tables from REPLACE INTO
|
||||||
|
✓ fromSql() → it records the table, not the schema, for qualified identifiers
|
||||||
|
✓ fromSql() → it handles quoted identifiers
|
||||||
|
✓ fromSql() → it ignores schema metadata tables
|
||||||
|
✓ fromSql() → it returns nothing for non-DML statements
|
||||||
|
✓ fromMigrationSource() → it extracts tables from Schema builder calls
|
||||||
|
✓ fromMigrationSource() → it extracts tables from raw DDL statements
|
||||||
|
✓ fromMigrationSource() → it records the table, not the schema, in qualified DDL and DML
|
||||||
|
✓ fromMigrationSource() → it extracts tables from DB::table calls
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\TestPaths
|
||||||
|
✓ isTestFile() → it matches a standard test file under a configured directory
|
||||||
|
✓ isTestFile() → it does not match a non-test file that shares the directory
|
||||||
|
✓ isTestFile() → it does not match files outside the configured directories
|
||||||
|
✓ isTestFile() → it matches an explicitly listed file
|
||||||
|
✓ isTestFile() → it honours a bare .php suffix
|
||||||
|
✓ isTestFile() → it does not require a dot before the suffix
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\ViteDepsHelper
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('plain-object')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('plain-array')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('nested')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('empty-object')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('empty-array')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-own-line')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-trailing')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('leading-line-comments')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-eof-no-newline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-leading')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-inline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-multiline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-eof')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('jsdoc-style-block')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-contains-double-slash')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-then-block')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-quotes')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-braces-commas')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-object')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-array')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-nested')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-newline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-block')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-line')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('crlf-line-comment')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('tabs-whitespace')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('url-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('glob-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('path-alias-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-open-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-close-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-both-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('star-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('single-slashes-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('escaped-quote-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('escaped-backslash-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('backslash-then-slash')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('newline-escape-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('unicode-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-secret')
|
||||||
|
✓ it never touches comment-looking sequences inside string values
|
||||||
|
✓ it removes the comment body entirely
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('basic')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('multiple-aliases')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('default-baseUrl')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('baseUrl-subdir')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('jsconfig-fallback')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('first-target-wins')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('multiple-keys-mixed')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('no-paths')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('no-compiler-options')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('key-without-glob')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('target-without-glob')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('target-not-array')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('target-empty-array')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('commented-tsconfig')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('trailing-comma-paths')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('malformed-json')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('missing-config')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('tsconfig-precedence')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('root-slash-literal')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('path-resolve')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('file-url')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('tilde-alias')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('multiple-aliases')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('laravel-plugin-default')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('no-config')
|
||||||
|
|
||||||
PASS Tests\Unit\Preset
|
PASS Tests\Unit\Preset
|
||||||
✓ preset invalid name
|
✓ preset invalid name
|
||||||
✓ preset → myFramework
|
✓ preset → myFramework
|
||||||
@@ -1868,6 +2152,7 @@
|
|||||||
✓ parallel
|
✓ parallel
|
||||||
✓ a parallel test can extend another test with same name
|
✓ a parallel test can extend another test with same name
|
||||||
✓ parallel reports invalid datasets as failures
|
✓ parallel reports invalid datasets as failures
|
||||||
|
✓ parallel can have multiple exclude-groups
|
||||||
|
|
||||||
PASS Tests\Visual\ParallelNestedDatasets
|
PASS Tests\Visual\ParallelNestedDatasets
|
||||||
✓ parallel loads nested datasets from nested directories
|
✓ parallel loads nested datasets from nested directories
|
||||||
@@ -1901,4 +2186,4 @@
|
|||||||
✓ pass with dataset with ('my-datas-set-value')
|
✓ pass with dataset with ('my-datas-set-value')
|
||||||
✓ within describe → pass with dataset with ('my-datas-set-value')
|
✓ within describe → pass with dataset with ('my-datas-set-value')
|
||||||
|
|
||||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1294 passed (2971 assertions)
|
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1542 passed (3375 assertions)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
dataset('throws', function () {
|
||||||
|
throw new RuntimeException('boom from dataset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('x', function ($a) {
|
||||||
|
expect($a)->toBeTrue();
|
||||||
|
})->with('throws');
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('references a missing dataset', function ($value) {
|
||||||
|
expect($value)->toBeTruthy();
|
||||||
|
})->with('missing');
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('passes normally', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('references a missing dataset', function ($value) {
|
||||||
|
expect($value)->toBeTruthy();
|
||||||
|
})->with('missing');
|
||||||
+2
-10
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Pest\Expectation;
|
use Pest\Expectation;
|
||||||
use Pest\Plugins\Tia\BaselineSync;
|
use Pest\Plugins\Tia\BaselineSync;
|
||||||
|
|
||||||
@@ -33,13 +35,3 @@ arch('globals')
|
|||||||
->expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep'])
|
->expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep'])
|
||||||
->not->toBeUsed()
|
->not->toBeUsed()
|
||||||
->ignoring(Expectation::class);
|
->ignoring(Expectation::class);
|
||||||
|
|
||||||
arch('contracts')
|
|
||||||
->expect('Pest\Contracts')
|
|
||||||
->toOnlyUse([
|
|
||||||
'NunoMaduro\Collision\Contracts',
|
|
||||||
'Pest\Factories\TestCaseMethodFactory',
|
|
||||||
'Symfony\Component\Console',
|
|
||||||
'Pest\Arch\Contracts',
|
|
||||||
'Pest\PendingCalls',
|
|
||||||
])->toBeInterfaces();
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user