mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 22:33:35 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d060742eb6 |
@@ -1,13 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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: [5.x]
|
branches: [4.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * *'
|
- cron: '0 9 * * *'
|
||||||
@@ -11,9 +11,6 @@ concurrency:
|
|||||||
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
static:
|
static:
|
||||||
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
||||||
@@ -24,16 +21,16 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
dependency-version: [prefer-stable]
|
dependency-version: [prefer-lowest, prefer-stable]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.3
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
coverage: none
|
coverage: none
|
||||||
extensions: sockets
|
extensions: sockets
|
||||||
@@ -44,17 +41,15 @@ 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
||||||
static-php-8.4-composer-
|
static-php-8.3-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
|
||||||
|
|||||||
+10
-12
@@ -2,7 +2,7 @@ name: Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [5.x]
|
branches: [4.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * *'
|
- cron: '0 9 * * *'
|
||||||
@@ -11,9 +11,6 @@ concurrency:
|
|||||||
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
||||||
@@ -24,18 +21,21 @@ jobs:
|
|||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||||
symfony: ['8.0']
|
symfony: ['7.4', '8.0']
|
||||||
php: ['8.4', '8.5']
|
php: ['8.3', '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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
@@ -48,10 +48,10 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
uses: actions/cache@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') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
|
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
|
||||||
${{ matrix.os }}-php-${{ matrix.php }}-composer-
|
${{ matrix.os }}-php-${{ matrix.php }}-composer-
|
||||||
@@ -63,8 +63,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install PHP dependencies
|
- name: Install PHP dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
|
||||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||||
|
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
.idea/*
|
.idea/*
|
||||||
.idea/codeStyleSettings.xml
|
.idea/codeStyleSettings.xml
|
||||||
.temp/*
|
.temp/*
|
||||||
/composer.lock
|
composer.lock
|
||||||
/vendor/
|
/vendor/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
|
|
||||||
|
|
||||||
## Ask before testing
|
|
||||||
|
|
||||||
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
|
|
||||||
|
|
||||||
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
|
|
||||||
|
|
||||||
Once the user confirms:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
composer test:unit # fast, excludes the visual group
|
|
||||||
composer test:integration # visual and snapshot tests
|
|
||||||
composer test # everything CI runs, in CI's order
|
|
||||||
composer update:snapshots # only when a test was added or removed
|
|
||||||
```
|
|
||||||
|
|
||||||
## TIA scenario tests
|
|
||||||
|
|
||||||
`tests/Features/Tia/*` scaffold a throwaway git project, run a real `pest` subprocess against it, and diff the TIA graph it wrote. They exist because TIA's contract is about what a run *writes* — replay, branch keys, and the COMPLETE / RESULTS-ONLY / HARD-SUPPRESSED tiers are invisible to ordinary assertions, and every case used to be measured by hand against a playground app.
|
|
||||||
|
|
||||||
Add one whenever a change touches branch resolution, replay, filtered mode, or the write tiers. How:
|
|
||||||
|
|
||||||
- `Project::make('master')` scaffolds; `seed('master')` writes a graph and sentinels every cached result (`time=9.999`, `assertions=42`) so any rewrite shows up.
|
|
||||||
- `$project->pest('--tia', …)` runs it; `$project->delta()` compares against that snapshot. `writtenCount()` is the discriminator — `0` means "replayed", not "wrote the same values". `mutateGraph()` bends one entry; overlays in `tests/Fixtures/Tia/overlays/<name>/` supply a different `tests/Pest.php`.
|
|
||||||
- Keep expectations driver-independent: a cold recording run needs pcov/xdebug and behaves differently without one. Seed a graph instead of recording one.
|
|
||||||
|
|
||||||
Run them by file (a directory argument finds nothing) or by `--filter`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
php bin/pest tests/Features/Tia/PartialRunWriteTier.php
|
|
||||||
```
|
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/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 (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://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
||||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
<a href="https://packagist.org/packages/pestphp/pest"><img alt="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 v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
|
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
||||||
|
|
||||||
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||||
|
|
||||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||||
- Follow the creator Nuno Maduro:
|
- Follow the creator Nuno Maduro:
|
||||||
@@ -29,13 +28,26 @@
|
|||||||
|
|
||||||
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)**.
|
||||||
|
|
||||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
|
||||||
- **[PhpStorm](https://jb.gg/nuno)**
|
### Platinum Sponsors
|
||||||
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
|
||||||
|
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
||||||
|
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
||||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
||||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
||||||
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
### Gold Sponsors
|
||||||
|
|
||||||
|
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||||
|
|
||||||
|
### Premium Sponsors
|
||||||
|
|
||||||
|
- [Zapiet](https://zapiet.com/?ref=pestphp)
|
||||||
|
- [Load Forge](https://loadforge.com/?ref=pestphp)
|
||||||
|
- [Route4Me](https://route4me.com/pt?ref=pestphp)
|
||||||
|
- [Nerdify](https://getnerdify.com/?ref=pestphp)
|
||||||
|
- [Akaunting](https://akaunting.com/?ref=pestphp)
|
||||||
|
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
|
||||||
|
|
||||||
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 v4 you should use the `4.x` branch instead.**
|
> **For Pest v3 you should use the `3.x` branch instead.**
|
||||||
|
|
||||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
|
||||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...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)
|
||||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||||
- Run the tests locally using: `composer test`
|
- Run the tests locally using: `composer test`
|
||||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Pest\Contracts\Restarter;
|
|
||||||
use Pest\Kernel;
|
use Pest\Kernel;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Support\Container;
|
|
||||||
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||||
@@ -144,7 +142,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
|
|
||||||
// Get $rootPath based on $autoloadPath
|
// Get $rootPath based on $autoloadPath
|
||||||
$rootPath = dirname($autoloadPath, 2);
|
$rootPath = dirname($autoloadPath, 2);
|
||||||
|
|
||||||
$input = new ArgvInput;
|
$input = new ArgvInput;
|
||||||
|
|
||||||
$testSuite = TestSuite::getInstance(
|
$testSuite = TestSuite::getInstance(
|
||||||
@@ -195,15 +192,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
|||||||
try {
|
try {
|
||||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||||
|
|
||||||
$container = Container::getInstance();
|
|
||||||
|
|
||||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
|
||||||
$restarter = $container->get($restarterClass);
|
|
||||||
assert($restarter instanceof Restarter);
|
|
||||||
|
|
||||||
$restarter->maybeRestart($rootPath, $originalArguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $kernel->handle($originalArguments, $arguments);
|
$result = $kernel->handle($originalArguments, $arguments);
|
||||||
|
|
||||||
$kernel->terminate();
|
$kernel->terminate();
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { readdir, readFile } from 'node:fs/promises'
|
|
||||||
import { existsSync } from 'node:fs'
|
|
||||||
import { createRequire } from 'node:module'
|
|
||||||
import { resolve, relative, extname, sep, join } from 'node:path'
|
|
||||||
import { pathToFileURL } from 'node:url'
|
|
||||||
|
|
||||||
const PAGE_EXTENSIONS = new Set([
|
|
||||||
'.vue', '.svelte',
|
|
||||||
'.tsx', '.jsx',
|
|
||||||
'.ts', '.js',
|
|
||||||
'.mts', '.cts', '.mjs', '.cjs',
|
|
||||||
])
|
|
||||||
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
|
|
||||||
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
|
||||||
const PAGE_DIR_CANDIDATES = [
|
|
||||||
'resources/js/Pages',
|
|
||||||
'resources/js/pages',
|
|
||||||
'assets/js/Pages',
|
|
||||||
'assets/js/pages',
|
|
||||||
'assets/Pages',
|
|
||||||
'assets/pages',
|
|
||||||
]
|
|
||||||
|
|
||||||
async function loadRolldown() {
|
|
||||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
|
||||||
let path = null
|
|
||||||
|
|
||||||
try { path = projectRequire.resolve('rolldown') } catch {}
|
|
||||||
|
|
||||||
if (path === null) {
|
|
||||||
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
|
||||||
// dependency of the vite package rather than a top-level install.
|
|
||||||
try {
|
|
||||||
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
|
||||||
path = viteRequire.resolve('rolldown')
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === null) return null
|
|
||||||
|
|
||||||
return await import(pathToFileURL(path).href)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stripJsonComments(raw) {
|
|
||||||
let out = ''
|
|
||||||
let inString = false
|
|
||||||
let quote = ''
|
|
||||||
let inLine = false
|
|
||||||
let inBlock = false
|
|
||||||
|
|
||||||
for (let i = 0; i < raw.length; i++) {
|
|
||||||
const c = raw[i]
|
|
||||||
const n = raw[i + 1]
|
|
||||||
|
|
||||||
if (inLine) {
|
|
||||||
if (c === '\n') { inLine = false; out += c }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (inBlock) {
|
|
||||||
if (c === '*' && n === '/') { inBlock = false; i++ }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (inString) {
|
|
||||||
out += c
|
|
||||||
if (c === '\\') { out += n ?? ''; i++; continue }
|
|
||||||
if (c === quote) inString = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
|
|
||||||
if (c === '/' && n === '/') { inLine = true; i++; continue }
|
|
||||||
if (c === '/' && n === '*') { inBlock = true; i++; continue }
|
|
||||||
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
|
|
||||||
out += c
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonWithComments(path) {
|
|
||||||
const raw = await readFile(path, 'utf8')
|
|
||||||
return JSON.parse(stripJsonComments(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
|
||||||
const alias = {}
|
|
||||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
||||||
const p = join(projectRoot, name)
|
|
||||||
if (!existsSync(p)) continue
|
|
||||||
let cfg
|
|
||||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
|
||||||
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
|
||||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
|
||||||
for (const [key, targets] of Object.entries(paths)) {
|
|
||||||
if (!key.endsWith('/*')) continue
|
|
||||||
const t0 = Array.isArray(targets) ? targets[0] : null
|
|
||||||
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
|
|
||||||
const aliasKey = key.slice(0, -2)
|
|
||||||
if (alias[aliasKey] !== undefined) continue
|
|
||||||
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alias
|
|
||||||
}
|
|
||||||
|
|
||||||
const VITE_CONFIG_FILES = [
|
|
||||||
'vite.config.ts',
|
|
||||||
'vite.config.js',
|
|
||||||
'vite.config.mjs',
|
|
||||||
'vite.config.cjs',
|
|
||||||
'vite.config.mts',
|
|
||||||
'vite.config.cts',
|
|
||||||
]
|
|
||||||
|
|
||||||
function resolveAliasTarget(projectRoot, target) {
|
|
||||||
if (target.startsWith('/')) {
|
|
||||||
// Vite resolves a leading slash against the project root, unless the
|
|
||||||
// config author used a genuinely absolute path.
|
|
||||||
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolve(projectRoot, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function usesLaravelVitePlugin(projectRoot) {
|
|
||||||
const p = join(projectRoot, 'package.json')
|
|
||||||
if (!existsSync(p)) return false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
|
||||||
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
|
||||||
const alias = {}
|
|
||||||
let source = null
|
|
||||||
|
|
||||||
for (const name of VITE_CONFIG_FILES) {
|
|
||||||
const p = join(projectRoot, name)
|
|
||||||
if (!existsSync(p)) continue
|
|
||||||
try { source = await readFile(p, 'utf8') } catch { continue }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source !== null) {
|
|
||||||
// The config is executable code we cannot import safely, so extract alias
|
|
||||||
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
|
||||||
// the value expression — covers `'@': '/resources/js'` as well as
|
|
||||||
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
|
||||||
// top-level comma; call arguments are kept via the paren group.
|
|
||||||
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
|
||||||
const key = m[1]
|
|
||||||
if (alias[key] !== undefined) continue
|
|
||||||
|
|
||||||
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
|
||||||
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
|
||||||
if (!target || target.includes('*')) continue
|
|
||||||
|
|
||||||
alias[key] = resolveAliasTarget(projectRoot, target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// laravel-vite-plugin registers '@' → resources/js by default.
|
|
||||||
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
|
||||||
alias['@'] = resolve(projectRoot, 'resources/js')
|
|
||||||
}
|
|
||||||
|
|
||||||
return alias
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listPageFiles(pagesDir) {
|
|
||||||
if (!existsSync(pagesDir)) return []
|
|
||||||
|
|
||||||
const out = []
|
|
||||||
const walk = async (dir) => {
|
|
||||||
let entries
|
|
||||||
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
||||||
for (const entry of entries) {
|
|
||||||
const full = resolve(dir, entry.name)
|
|
||||||
if (entry.isDirectory()) { await walk(full); continue }
|
|
||||||
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await walk(pagesDir)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverPagesDir() {
|
|
||||||
const override = process.env.TIA_VITE_PAGES_DIR
|
|
||||||
if (override && override.length > 0) {
|
|
||||||
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const rel of PAGE_DIR_CANDIDATES) {
|
|
||||||
const abs = resolve(PROJECT_ROOT, rel)
|
|
||||||
if (!existsSync(abs)) continue
|
|
||||||
const files = await listPageFiles(abs)
|
|
||||||
if (files.length > 0) return abs
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function componentNameFor(pageAbs, pagesDir) {
|
|
||||||
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
|
||||||
const ext = extname(rel)
|
|
||||||
return rel.slice(0, rel.length - ext.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLocalSpecifier(source, aliasKeys) {
|
|
||||||
if (source.startsWith('.') || source.startsWith('/')) return true
|
|
||||||
for (const key of aliasKeys) {
|
|
||||||
if (source === key || source.startsWith(key + '/')) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pagesDir = await discoverPagesDir()
|
|
||||||
|
|
||||||
if (pagesDir === null) {
|
|
||||||
process.stdout.write('{}')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = await listPageFiles(pagesDir)
|
|
||||||
|
|
||||||
if (pages.length === 0) {
|
|
||||||
process.stdout.write('{}')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const loaded = await loadRolldown()
|
|
||||||
|
|
||||||
if (loaded === null) {
|
|
||||||
process.stdout.write('{}')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rolldown } = loaded
|
|
||||||
// The vite config is what the dev server actually resolves with — let it win.
|
|
||||||
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
|
||||||
const aliasKeys = Object.keys(alias)
|
|
||||||
|
|
||||||
const graph = new Map()
|
|
||||||
|
|
||||||
const collector = {
|
|
||||||
name: 'pest-tia-collector',
|
|
||||||
moduleParsed(info) {
|
|
||||||
const id = info.id
|
|
||||||
if (!id || id.startsWith('\0')) return
|
|
||||||
const deps = new Set()
|
|
||||||
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
|
||||||
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
|
||||||
graph.set(id, deps)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const externalBare = {
|
|
||||||
name: 'pest-tia-external-bare',
|
|
||||||
resolveId(source) {
|
|
||||||
if (!source) return null
|
|
||||||
if (isLocalSpecifier(source, aliasKeys)) return null
|
|
||||||
return { id: source, external: true }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetStub = {
|
|
||||||
name: 'pest-tia-asset-stub',
|
|
||||||
load(id) {
|
|
||||||
if (!id) return null
|
|
||||||
if (ASSET_EXT_RE.test(id)) {
|
|
||||||
return { code: 'export default null', moduleSideEffects: false }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = Object.create(null)
|
|
||||||
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
|
||||||
|
|
||||||
const bundle = await rolldown({
|
|
||||||
input,
|
|
||||||
cwd: PROJECT_ROOT,
|
|
||||||
resolve: {
|
|
||||||
alias,
|
|
||||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
|
||||||
},
|
|
||||||
transform: { jsx: 'preserve' },
|
|
||||||
treeshake: false,
|
|
||||||
plugins: [externalBare, assetStub, collector],
|
|
||||||
logLevel: 'silent',
|
|
||||||
onLog: () => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await bundle.generate({ format: 'esm' })
|
|
||||||
} finally {
|
|
||||||
await bundle.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
const reverse = new Map()
|
|
||||||
const transitiveCache = new Map()
|
|
||||||
|
|
||||||
const computeTransitive = (id, stack) => {
|
|
||||||
const cached = transitiveCache.get(id)
|
|
||||||
if (cached) return cached
|
|
||||||
if (stack.has(id)) return null
|
|
||||||
|
|
||||||
stack.add(id)
|
|
||||||
const acc = new Set()
|
|
||||||
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
|
||||||
// that dependency's subtree and must not be memoized — the ancestor call
|
|
||||||
// completes it for the current traversal, but a cached copy would leak the
|
|
||||||
// incomplete set into other pages' traversals.
|
|
||||||
let complete = true
|
|
||||||
const deps = graph.get(id)
|
|
||||||
if (deps) {
|
|
||||||
for (const dep of deps) {
|
|
||||||
if (!dep || dep.startsWith('\0')) continue
|
|
||||||
if (dep.startsWith(PROJECT_ROOT)) {
|
|
||||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
|
||||||
acc.add(rel)
|
|
||||||
}
|
|
||||||
if (stack.has(dep)) {
|
|
||||||
complete = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const child = computeTransitive(dep, stack)
|
|
||||||
if (child) for (const r of child) acc.add(r)
|
|
||||||
if (!transitiveCache.has(dep)) complete = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stack.delete(id)
|
|
||||||
if (complete) transitiveCache.set(id, acc)
|
|
||||||
return acc
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
const pageComponent = componentNameFor(page, pagesDir)
|
|
||||||
const reachable = computeTransitive(page, new Set())
|
|
||||||
if (!reachable) continue
|
|
||||||
for (const rel of reachable) {
|
|
||||||
const bucket = reverse.get(rel) ?? new Set()
|
|
||||||
bucket.add(pageComponent)
|
|
||||||
reverse.set(rel, bucket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = Object.create(null)
|
|
||||||
const keys = [...reverse.keys()].sort()
|
|
||||||
for (const key of keys) {
|
|
||||||
payload[key] = [...reverse.get(key)].sort()
|
|
||||||
}
|
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
const invokedDirectly = process.argv[1] !== undefined
|
|
||||||
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
|
||||||
|
|
||||||
if (invokedDirectly) {
|
|
||||||
try {
|
|
||||||
await main()
|
|
||||||
} catch (err) {
|
|
||||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ 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;
|
||||||
@@ -59,15 +58,6 @@ $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));
|
||||||
|
|||||||
+15
-20
@@ -17,20 +17,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.3.0",
|
||||||
"brianium/paratest": "^7.23.1",
|
"brianium/paratest": "^7.20.0",
|
||||||
"nunomaduro/collision": "^8.9.5",
|
"nunomaduro/collision": "^11.0.0",
|
||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^5.0.0",
|
"pestphp/pest-plugin": "^4.0.0",
|
||||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||||
"pestphp/pest-plugin-mutate": "^5.0.1",
|
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||||
"phpunit/phpunit": "^13.2.6",
|
"phpunit/phpunit": "^12.5.16",
|
||||||
"symfony/process": "^8.1.0"
|
"symfony/process": "^7.4.8|^8.0.8"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"filp/whoops": "<2.18.3",
|
"filp/whoops": "<2.18.3",
|
||||||
"phpunit/phpunit": ">13.2.6",
|
"phpunit/phpunit": ">12.5.16",
|
||||||
"sebastian/exporter": "<7.0.0",
|
"sebastian/exporter": "<7.0.0",
|
||||||
"webmozart/assert": "<1.11.0"
|
"webmozart/assert": "<1.11.0"
|
||||||
},
|
},
|
||||||
@@ -48,7 +48,6 @@
|
|||||||
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
||||||
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
||||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||||
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
|
|
||||||
"Tests\\": "tests/PHPUnit/"
|
"Tests\\": "tests/PHPUnit/"
|
||||||
},
|
},
|
||||||
"classmap": [
|
"classmap": [
|
||||||
@@ -59,19 +58,17 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"pestphp/pest-dev-tools": "^5.0.0",
|
"mrpunyapal/peststan": "^0.2.5",
|
||||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
"pestphp/pest-dev-tools": "^4.1.0",
|
||||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||||
"pestphp/pest-plugin-rector": "^5.0.2",
|
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||||
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
"psy/psysh": "^0.12.22"
|
||||||
"psy/psysh": "^0.12.24"
|
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"config": {
|
"config": {
|
||||||
"sort-packages": true,
|
"sort-packages": true,
|
||||||
"preferred-install": "dist",
|
"preferred-install": "dist",
|
||||||
"process-timeout": 0,
|
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"pestphp/pest-plugin": true
|
"pestphp/pest-plugin": true
|
||||||
}
|
}
|
||||||
@@ -95,7 +92,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 --group=tia -v",
|
|
||||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||||
"test": [
|
"test": [
|
||||||
"@test:lint",
|
"@test:lint",
|
||||||
@@ -127,7 +123,6 @@
|
|||||||
"Pest\\Plugins\\Verbose",
|
"Pest\\Plugins\\Verbose",
|
||||||
"Pest\\Plugins\\Version",
|
"Pest\\Plugins\\Version",
|
||||||
"Pest\\Plugins\\Shard",
|
"Pest\\Plugins\\Shard",
|
||||||
"Pest\\Plugins\\Tia",
|
|
||||||
"Pest\\Plugins\\Parallel"
|
"Pest\\Plugins\\Parallel"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,39 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/*
|
|
||||||
* BSD 3-Clause License
|
|
||||||
*
|
|
||||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* Redistribution and use in source and binary forms, with or without
|
|
||||||
* modification, are permitted provided that the following conditions are met:
|
|
||||||
*
|
|
||||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
* list of conditions and the following disclaimer.
|
|
||||||
*
|
|
||||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
* this list of conditions and the following disclaimer in the documentation
|
|
||||||
* and/or other materials provided with the distribution.
|
|
||||||
*
|
|
||||||
* 3. Neither the name of the copyright holder nor the names of its
|
|
||||||
* contributors may be used to endorse or promote products derived from
|
|
||||||
* this software without specific prior written permission.
|
|
||||||
*
|
|
||||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
||||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This file is part of PHPUnit.
|
* This file is part of PHPUnit.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The MIT License (MIT)
|
|
||||||
*
|
|
||||||
* Copyright (c) 2011 Brian Scaturro
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in
|
|
||||||
* all copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
* THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace ParaTest\WrapperRunner;
|
|
||||||
|
|
||||||
use PHPUnit\TextUI\Output\Printer;
|
|
||||||
|
|
||||||
use function preg_match;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*
|
|
||||||
* This file is overridden so the "T" progress character — emitted by Pest for
|
|
||||||
* "todo" tests — is routed to the progress file next to the regular characters,
|
|
||||||
* instead of being treated as unexpected output.
|
|
||||||
*/
|
|
||||||
final readonly class ProgressPrinterOutput implements Printer
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Printer $progressPrinter,
|
|
||||||
private Printer $outputPrinter,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function print(string $buffer): void
|
|
||||||
{
|
|
||||||
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
|
||||||
if (
|
|
||||||
$buffer === "\n"
|
|
||||||
|| preg_match('/^ +$/', $buffer) === 1
|
|
||||||
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
match ($buffer) {
|
|
||||||
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
|
||||||
default => $this->outputPrinter->print($buffer),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function flush(): void
|
|
||||||
{
|
|
||||||
$this->progressPrinter->flush();
|
|
||||||
$this->outputPrinter->flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
|||||||
(static function () use ($suiteClassFile) {
|
(static function () use ($suiteClassFile) {
|
||||||
try {
|
try {
|
||||||
include_once $suiteClassFile;
|
include_once $suiteClassFile;
|
||||||
|
|
||||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Panic::with($e);
|
Panic::with($e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$loadedClasses = array_values(
|
$loadedClasses = array_values(
|
||||||
|
|||||||
@@ -1,388 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/*
|
|
||||||
* BSD 3-Clause License
|
|
||||||
*
|
|
||||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* Redistribution and use in source and binary forms, with or without
|
|
||||||
* modification, are permitted provided that the following conditions are met:
|
|
||||||
*
|
|
||||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
* list of conditions and the following disclaimer.
|
|
||||||
*
|
|
||||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
* this list of conditions and the following disclaimer in the documentation
|
|
||||||
* and/or other materials provided with the distribution.
|
|
||||||
*
|
|
||||||
* 3. Neither the name of the copyright holder nor the names of its
|
|
||||||
* contributors may be used to endorse or promote products derived from
|
|
||||||
* this software without specific prior written permission.
|
|
||||||
*
|
|
||||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
||||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of PHPUnit.
|
|
||||||
*
|
|
||||||
* (c) Sebastian Bergmann <sebastian@phpunit.de>
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view the LICENSE
|
|
||||||
* file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace PHPUnit\Runner;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\DataProviderTestSuite;
|
|
||||||
use PHPUnit\Framework\Reorderable;
|
|
||||||
use PHPUnit\Framework\Test;
|
|
||||||
use PHPUnit\Framework\TestCase;
|
|
||||||
use PHPUnit\Framework\TestSuite;
|
|
||||||
use PHPUnit\Runner\ResultCache\NullResultCache;
|
|
||||||
use PHPUnit\Runner\ResultCache\ResultCache;
|
|
||||||
use PHPUnit\Runner\ResultCache\ResultCacheId;
|
|
||||||
|
|
||||||
use function array_diff;
|
|
||||||
use function array_merge;
|
|
||||||
use function array_reverse;
|
|
||||||
use function array_splice;
|
|
||||||
use function assert;
|
|
||||||
use function count;
|
|
||||||
use function in_array;
|
|
||||||
use function max;
|
|
||||||
use function shuffle;
|
|
||||||
use function usort;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
|
||||||
*/
|
|
||||||
final class TestSuiteSorter
|
|
||||||
{
|
|
||||||
public const int ORDER_DEFAULT = 0;
|
|
||||||
|
|
||||||
public const int ORDER_RANDOMIZED = 1;
|
|
||||||
|
|
||||||
public const int ORDER_REVERSED = 2;
|
|
||||||
|
|
||||||
public const int ORDER_DEFECTS_FIRST = 3;
|
|
||||||
|
|
||||||
public const int ORDER_DURATION = 4;
|
|
||||||
|
|
||||||
public const int ORDER_SIZE = 5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var non-empty-array<non-empty-string, positive-int>
|
|
||||||
*/
|
|
||||||
private const array SIZE_SORT_WEIGHT = [
|
|
||||||
'small' => 1,
|
|
||||||
'medium' => 2,
|
|
||||||
'large' => 3,
|
|
||||||
'unknown' => 4,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, int> Associative array of (string => DEFECT_SORT_WEIGHT) elements
|
|
||||||
*/
|
|
||||||
private array $defectSortOrder = [];
|
|
||||||
|
|
||||||
private readonly ResultCache $cache;
|
|
||||||
|
|
||||||
public function __construct(?ResultCache $cache = null)
|
|
||||||
{
|
|
||||||
$this->cache = $cache ?? new NullResultCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void
|
|
||||||
{
|
|
||||||
$allowedOrders = [
|
|
||||||
self::ORDER_DEFAULT,
|
|
||||||
self::ORDER_REVERSED,
|
|
||||||
self::ORDER_RANDOMIZED,
|
|
||||||
self::ORDER_DURATION,
|
|
||||||
self::ORDER_SIZE,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! in_array($order, $allowedOrders, true)) {
|
|
||||||
// @codeCoverageIgnoreStart
|
|
||||||
throw new InvalidOrderException;
|
|
||||||
// @codeCoverageIgnoreEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowedOrderDefects = [
|
|
||||||
self::ORDER_DEFAULT,
|
|
||||||
self::ORDER_DEFECTS_FIRST,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (! in_array($orderDefects, $allowedOrderDefects, true)) {
|
|
||||||
// @codeCoverageIgnoreStart
|
|
||||||
throw new InvalidOrderException;
|
|
||||||
// @codeCoverageIgnoreEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($suite instanceof TestSuite) {
|
|
||||||
foreach ($suite as $_suite) {
|
|
||||||
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
|
|
||||||
$this->addSuiteToDefectSortOrder($suite);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->sort($suite, $order, $resolveDependencies, $orderDefects);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
|
|
||||||
{
|
|
||||||
if ($suite->tests() === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($order === self::ORDER_REVERSED) {
|
|
||||||
$suite->setTests($this->reverse($suite->tests()));
|
|
||||||
} elseif ($order === self::ORDER_RANDOMIZED) {
|
|
||||||
$suite->setTests($this->randomize($suite->tests()));
|
|
||||||
} elseif ($order === self::ORDER_DURATION) {
|
|
||||||
$suite->setTests($this->sortByDuration($suite->tests()));
|
|
||||||
} elseif ($order === self::ORDER_SIZE) {
|
|
||||||
$suite->setTests($this->sortBySize($suite->tests()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
|
|
||||||
$suite->setTests($this->sortDefectsFirst($suite->tests()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
|
|
||||||
$tests = $suite->tests();
|
|
||||||
|
|
||||||
/** @noinspection PhpParamsInspection */
|
|
||||||
/** @phpstan-ignore argument.type */
|
|
||||||
$suite->setTests($this->resolveDependencies($tests));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addSuiteToDefectSortOrder(TestSuite $suite): void
|
|
||||||
{
|
|
||||||
$max = 0;
|
|
||||||
|
|
||||||
foreach ($suite->tests() as $test) {
|
|
||||||
assert($test instanceof Reorderable);
|
|
||||||
|
|
||||||
$sortId = $test->sortId();
|
|
||||||
|
|
||||||
if (! isset($this->defectSortOrder[$sortId])) {
|
|
||||||
$this->defectSortOrder[$sortId] = $this->cache->status(ResultCacheId::fromReorderable($test))->asInt();
|
|
||||||
$max = max($max, $this->defectSortOrder[$sortId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->defectSortOrder[$suite->sortId()] = $max;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Test> $tests
|
|
||||||
* @return list<Test>
|
|
||||||
*/
|
|
||||||
private function reverse(array $tests): array
|
|
||||||
{
|
|
||||||
return array_reverse($tests);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Test> $tests
|
|
||||||
* @return list<Test>
|
|
||||||
*/
|
|
||||||
private function randomize(array $tests): array
|
|
||||||
{
|
|
||||||
shuffle($tests);
|
|
||||||
|
|
||||||
return $tests;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Test> $tests
|
|
||||||
* @return list<Test>
|
|
||||||
*/
|
|
||||||
private function sortDefectsFirst(array $tests): array
|
|
||||||
{
|
|
||||||
usort(
|
|
||||||
$tests,
|
|
||||||
fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $tests;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Test> $tests
|
|
||||||
* @return list<Test>
|
|
||||||
*/
|
|
||||||
private function sortByDuration(array $tests): array
|
|
||||||
{
|
|
||||||
usort(
|
|
||||||
$tests,
|
|
||||||
fn (Test $left, Test $right) => $this->cmpDuration($left, $right),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $tests;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Test> $tests
|
|
||||||
* @return list<Test>
|
|
||||||
*/
|
|
||||||
private function sortBySize(array $tests): array
|
|
||||||
{
|
|
||||||
usort(
|
|
||||||
$tests,
|
|
||||||
fn (Test $left, Test $right) => $this->cmpSize($left, $right),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $tests;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comparator callback function to sort tests for "reach failure as fast as possible".
|
|
||||||
*
|
|
||||||
* 1. sort tests by defect weight defined in self::DEFECT_SORT_WEIGHT
|
|
||||||
* 2. when tests are equally defective, sort the fastest to the front
|
|
||||||
* 3. do not reorder successful tests
|
|
||||||
*/
|
|
||||||
private function cmpDefectPriorityAndTime(Test $a, Test $b): int
|
|
||||||
{
|
|
||||||
assert($a instanceof Reorderable);
|
|
||||||
assert($b instanceof Reorderable);
|
|
||||||
|
|
||||||
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
|
|
||||||
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
|
|
||||||
|
|
||||||
if ($priorityA !== $priorityB) {
|
|
||||||
// Sort defect weight descending
|
|
||||||
return $priorityB <=> $priorityA;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($priorityA > 0 || $priorityB > 0) {
|
|
||||||
return $this->cmpDuration($a, $b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// do not change execution order
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares test duration for sorting tests by duration ascending.
|
|
||||||
*/
|
|
||||||
private function cmpDuration(Test $a, Test $b): int
|
|
||||||
{
|
|
||||||
if (! ($a instanceof Reorderable && $b instanceof Reorderable)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares test size for sorting tests small->medium->large->unknown.
|
|
||||||
*/
|
|
||||||
private function cmpSize(Test $a, Test $b): int
|
|
||||||
{
|
|
||||||
$sizeA = ($a instanceof TestCase || $a instanceof DataProviderTestSuite)
|
|
||||||
? $a->size()->asString()
|
|
||||||
: 'unknown';
|
|
||||||
$sizeB = ($b instanceof TestCase || $b instanceof DataProviderTestSuite)
|
|
||||||
? $b->size()->asString()
|
|
||||||
: 'unknown';
|
|
||||||
|
|
||||||
return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible.
|
|
||||||
* The algorithm will leave the tests in original running order when it can.
|
|
||||||
* For more details see the documentation for test dependencies.
|
|
||||||
*
|
|
||||||
* Short description of algorithm:
|
|
||||||
* 1. Pick the next Test from remaining tests to be checked for dependencies.
|
|
||||||
* 2. If the test has no dependencies: mark done, start again from the top
|
|
||||||
* 3. If the test has dependencies but none left to do: mark done, start again from the top
|
|
||||||
* 4. When we reach the end add any leftover tests to the end. These will be marked 'skipped' during execution.
|
|
||||||
*
|
|
||||||
* @param array<TestCase> $tests
|
|
||||||
* @return array<TestCase>
|
|
||||||
*/
|
|
||||||
private function resolveDependencies(array $tests): array
|
|
||||||
{
|
|
||||||
// Pest: Fast-path. If no test in this suite declares dependencies, the
|
|
||||||
// original O(N^2) algorithm is wasted work — it would splice each test
|
|
||||||
// one-by-one back into the same order. The check deliberately walks
|
|
||||||
// TestCase instances directly instead of calling TestSuite::requires(),
|
|
||||||
// because the latter lazily builds TestSuite::provides() via
|
|
||||||
// ExecutionOrderDependency::mergeUnique, which is O(N^2) in the total
|
|
||||||
// number of tests. With thousands of tests that single call alone can
|
|
||||||
// burn several seconds before the sort even begins. Reading the
|
|
||||||
// cached TestCase::$dependencies property stays O(N) and costs nothing
|
|
||||||
// when no test uses `->depends()` / PHPUnit `@depends`.
|
|
||||||
if (! $this->anyTestHasDependencies($tests)) {
|
|
||||||
return $tests;
|
|
||||||
}
|
|
||||||
|
|
||||||
$newTestOrder = [];
|
|
||||||
$i = 0;
|
|
||||||
$provided = [];
|
|
||||||
|
|
||||||
do {
|
|
||||||
if (array_diff($tests[$i]->requires(), $provided) === []) {
|
|
||||||
$provided = array_merge($provided, $tests[$i]->provides());
|
|
||||||
$newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1));
|
|
||||||
$i = 0;
|
|
||||||
} else {
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
} while ($tests !== [] && ($i < count($tests)));
|
|
||||||
|
|
||||||
return array_merge($newTestOrder, $tests);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cheaply determines whether any test in the tree declares @depends.
|
|
||||||
*
|
|
||||||
* Walks `TestSuite` containers recursively and inspects each `TestCase`
|
|
||||||
* directly so it never triggers `TestSuite::provides()`, which is O(N^2)
|
|
||||||
* in the total number of aggregated tests.
|
|
||||||
*
|
|
||||||
* @param iterable<Test> $tests
|
|
||||||
*/
|
|
||||||
private function anyTestHasDependencies(iterable $tests): bool
|
|
||||||
{
|
|
||||||
foreach ($tests as $test) {
|
|
||||||
if ($test instanceof TestSuite) {
|
|
||||||
if ($this->anyTestHasDependencies($test->tests())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($test instanceof TestCase && $test->requires() !== []) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-10
@@ -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 PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
namespace Pest\Logging\TeamCity\Subscriber;
|
||||||
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
use PHPUnit\Event\Test\Skipped;
|
||||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||||
@@ -51,20 +51,16 @@ 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 readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||||
{
|
{
|
||||||
public function notify(Skipped $event): void
|
public function notify(Skipped $event): void
|
||||||
{
|
{
|
||||||
if ($event->message() === '__TODO__') {
|
if (str_contains($event->message(), '__TODO__')) {
|
||||||
$this->printTodoItem();
|
$this->printTodoItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->printer()->testSkipped();
|
$this->logger()->testSkipped($event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +69,7 @@ final readonly class TestSkippedSubscriber extends Subscriber implements Skipped
|
|||||||
private function printTodoItem(): void
|
private function printTodoItem(): void
|
||||||
{
|
{
|
||||||
$mirror = new ReflectionClass($this->printer());
|
$mirror = new ReflectionClass($this->printer());
|
||||||
$printProgress = $mirror->getMethod('printProgress');
|
$printerMirror = $mirror->getMethod('printProgress');
|
||||||
$printProgress->invoke($this->printer(), 'T');
|
$printerMirror->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/pestphp/pest-plugin-phpstan/extension.neon
|
- vendor/mrpunyapal/peststan/extension.neon
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
level: 7
|
level: 7
|
||||||
|
|||||||
+1
-2
@@ -18,9 +18,8 @@
|
|||||||
<directory suffix=".php">./tests</directory>
|
<directory suffix=".php">./tests</directory>
|
||||||
<directory suffix=".php">./tests-external</directory>
|
<directory suffix=".php">./tests-external</directory>
|
||||||
<exclude>./tests/.snapshots</exclude>
|
<exclude>./tests/.snapshots</exclude>
|
||||||
|
<exclude>./tests/.tests</exclude>
|
||||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||||
<exclude>./tests/Fixtures/Suites</exclude>
|
|
||||||
<exclude>./tests/Fixtures/Tia</exclude>
|
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
|
|||||||
-39
@@ -2,61 +2,22 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Pest\Rector\Rules\UseToMatchArrayRector;
|
|
||||||
use Pest\Rector\Set\PestSetList;
|
|
||||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||||
use Rector\Config\RectorConfig;
|
use Rector\Config\RectorConfig;
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
|
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
|
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
|
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
|
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
|
|
||||||
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
|
|
||||||
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
|
||||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||||
|
|
||||||
return RectorConfig::configure()
|
return RectorConfig::configure()
|
||||||
->withPaths([
|
->withPaths([
|
||||||
__DIR__.'/src',
|
__DIR__.'/src',
|
||||||
__DIR__.'/tests',
|
|
||||||
])
|
|
||||||
->withSets([
|
|
||||||
PestSetList::CODING_STYLE,
|
|
||||||
])
|
])
|
||||||
->withSkip([
|
->withSkip([
|
||||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||||
__DIR__.'/tests/Fixtures/Arch',
|
|
||||||
__DIR__.'/tests/Fixtures/Suites',
|
|
||||||
ReturnNeverTypeRector::class,
|
ReturnNeverTypeRector::class,
|
||||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||||
NarrowObjectReturnTypeRector::class,
|
NarrowObjectReturnTypeRector::class,
|
||||||
RemoveParentDelegatingConstructorRector::class,
|
RemoveParentDelegatingConstructorRector::class,
|
||||||
RemoveDuplicatedReturnSelfDocblockRector::class,
|
|
||||||
RemoveUselessUnionReturnDocblockRector::class,
|
|
||||||
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
|
||||||
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
|
||||||
],
|
|
||||||
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
|
||||||
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
|
||||||
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
|
||||||
UseToMatchArrayRector::class,
|
|
||||||
// Test fixtures rely on "unused" constructors, params and properties
|
|
||||||
// (resolved via the container or read through reflection), so the
|
|
||||||
// dead-code and return-type rules below must not touch the test suite.
|
|
||||||
RemoveEmptyClassMethodRector::class => [
|
|
||||||
__DIR__.'/tests',
|
|
||||||
],
|
|
||||||
RemoveUnusedConstructorParamRector::class => [
|
|
||||||
__DIR__.'/tests',
|
|
||||||
],
|
|
||||||
RemoveUnusedPrivatePropertyRector::class => [
|
|
||||||
__DIR__.'/tests',
|
|
||||||
],
|
|
||||||
AddArrowFunctionReturnTypeRector::class => [
|
|
||||||
__DIR__.'/tests',
|
|
||||||
],
|
|
||||||
])
|
])
|
||||||
->withPreparedSets(
|
->withPreparedSets(
|
||||||
deadCode: true,
|
deadCode: true,
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
---
|
|
||||||
name: pest-testing
|
|
||||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
@php
|
|
||||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
|
||||||
@endphp
|
|
||||||
# Pest Testing 5
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
### Creating Tests
|
|
||||||
|
|
||||||
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
|
||||||
|
|
||||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
|
||||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
|
||||||
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
|
||||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
|
||||||
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
|
||||||
|
|
||||||
### Test Organization
|
|
||||||
|
|
||||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
|
||||||
- Browser tests: `tests/Browser/` directory.
|
|
||||||
- Do NOT remove tests without approval - these are core application code.
|
|
||||||
|
|
||||||
### Basic Test Structure
|
|
||||||
|
|
||||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
|
||||||
|
|
||||||
@boostsnippet("Basic Pest Test Example", "php")
|
|
||||||
it('is true', function () {
|
|
||||||
expect(true)->toBeTrue();
|
|
||||||
});
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
|
||||||
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
|
||||||
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
|
||||||
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
|
||||||
|
|
||||||
## Assertions
|
|
||||||
|
|
||||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
|
||||||
|
|
||||||
@boostsnippet("Pest Response Assertion", "php")
|
|
||||||
it('returns all', function () {
|
|
||||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
|
||||||
});
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
| Use | Instead of |
|
|
||||||
|-----|------------|
|
|
||||||
| `assertSuccessful()` | `assertStatus(200)` |
|
|
||||||
| `assertNotFound()` | `assertStatus(404)` |
|
|
||||||
| `assertForbidden()` | `assertStatus(403)` |
|
|
||||||
|
|
||||||
## Mocking
|
|
||||||
|
|
||||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
|
||||||
|
|
||||||
## Datasets
|
|
||||||
|
|
||||||
Use datasets for repetitive tests (validation rules, etc.):
|
|
||||||
|
|
||||||
@boostsnippet("Pest Dataset Example", "php")
|
|
||||||
it('has emails', function (string $email) {
|
|
||||||
expect($email)->not->toBeEmpty();
|
|
||||||
})->with([
|
|
||||||
'james' => 'james@laravel.com',
|
|
||||||
'taylor' => 'taylor@laravel.com',
|
|
||||||
]);
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
## Pest 5 Features
|
|
||||||
|
|
||||||
| Feature | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
|
||||||
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
|
||||||
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
|
||||||
| Browser Testing | Full integration tests in real browsers |
|
|
||||||
| Smoke Testing | Validate multiple pages quickly |
|
|
||||||
| Visual Regression | Compare screenshots for visual changes |
|
|
||||||
| Architecture Testing | Enforce code conventions |
|
|
||||||
|
|
||||||
### Tia (Test Impact Analysis)
|
|
||||||
|
|
||||||
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
|
||||||
|
|
||||||
@boostsnippet("Tia Example", "shell")
|
|
||||||
./vendor/bin/pest --parallel --tia
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
|
||||||
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
|
||||||
|
|
||||||
### New Validation Expectations
|
|
||||||
|
|
||||||
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
|
||||||
|
|
||||||
@boostsnippet("Pest 5 Validation Expectations", "php")
|
|
||||||
expect('nuno@pestphp.com')->toBeEmail();
|
|
||||||
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
|
||||||
expect('192.168.1.1')->toBeIpAddress();
|
|
||||||
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
|
||||||
expect('example.com')->toBeHostname();
|
|
||||||
expect('example.co.uk')->toBeDomain();
|
|
||||||
expect('Zm9vYmFy')->toBeBase64();
|
|
||||||
expect('deadbeef')->toBeHexadecimal();
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
### Time-Balanced Sharding
|
|
||||||
|
|
||||||
Distribute tests across CI shards by execution time rather than count:
|
|
||||||
|
|
||||||
@boostsnippet("Pest Sharding Example", "shell")
|
|
||||||
./vendor/bin/pest --update-shards
|
|
||||||
./vendor/bin/pest --shard=1/4
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
|
||||||
|
|
||||||
### Browser Test Example
|
|
||||||
|
|
||||||
Browser tests run in real browsers for full integration testing:
|
|
||||||
|
|
||||||
- Browser tests live in `tests/Browser/`.
|
|
||||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
|
||||||
- Use `RefreshDatabase` for clean state per test.
|
|
||||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
|
||||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
|
||||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
|
||||||
- Switch color schemes (light/dark mode) when appropriate.
|
|
||||||
- Take screenshots or pause tests for debugging.
|
|
||||||
|
|
||||||
@boostsnippet("Pest Browser Test Example", "php")
|
|
||||||
it('may reset the password', function () {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$page = visit('/sign-in');
|
|
||||||
|
|
||||||
$page->assertSee('Sign In')
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->click('Forgot Password?')
|
|
||||||
->fill('email', 'nuno@laravel.com')
|
|
||||||
->click('Send Reset Link')
|
|
||||||
->assertSee('We have emailed your password reset link!');
|
|
||||||
|
|
||||||
Notification::assertSent(ResetPassword::class);
|
|
||||||
});
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
### Smoke Testing
|
|
||||||
|
|
||||||
Quickly validate multiple pages have no JavaScript errors:
|
|
||||||
|
|
||||||
@boostsnippet("Pest Smoke Testing Example", "php")
|
|
||||||
$pages = visit(['/', '/about', '/contact']);
|
|
||||||
|
|
||||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
### Visual Regression Testing
|
|
||||||
|
|
||||||
Capture and compare screenshots to detect visual changes.
|
|
||||||
|
|
||||||
### Architecture Testing
|
|
||||||
|
|
||||||
@boostsnippet("Architecture Test Example", "php")
|
|
||||||
arch('controllers')
|
|
||||||
->expect('App\Http\Controllers')
|
|
||||||
->toExtendNothing()
|
|
||||||
->toHaveSuffix('Controller');
|
|
||||||
@endboostsnippet
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
|
||||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
|
||||||
- Forgetting datasets for repetitive validation tests
|
|
||||||
- Deleting tests without approval
|
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
|
||||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
|
||||||
@@ -5,8 +5,6 @@
|
|||||||
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
||||||
'INFO' => ['blue', 'INFO'],
|
'INFO' => ['blue', 'INFO'],
|
||||||
'ERROR' => ['red', 'ERROR'],
|
'ERROR' => ['red', 'ERROR'],
|
||||||
'WARN' => ['yellow', 'WARN'],
|
|
||||||
'SUCCESS' => ['green', 'SUCCESS'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray mr-1">- </span>
|
<span class="text-gray mr-1">- </span>
|
||||||
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -176,5 +176,9 @@ final class Laravel extends AbstractPreset
|
|||||||
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
|
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
|
||||||
->toHaveAttribute('Attribute')
|
->toHaveAttribute('Attribute')
|
||||||
->toHaveMethod('resolve');
|
->toHaveMethod('resolve');
|
||||||
|
|
||||||
|
$this->expectations[] = expect('App\Rules')
|
||||||
|
->classes()
|
||||||
|
->toImplement('Illuminate\Contracts\Validation\ValidationRule');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ final class BootOverrides implements Bootstrapper
|
|||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
public const array FILES = [
|
public const array FILES = [
|
||||||
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
|
||||||
'Runner/Filter/NameFilterIterator.php',
|
'Runner/Filter/NameFilterIterator.php',
|
||||||
'Runner/ResultCache/DefaultResultCache.php',
|
'Runner/ResultCache/DefaultResultCache.php',
|
||||||
'Runner/TestSuiteLoader.php',
|
'Runner/TestSuiteLoader.php',
|
||||||
'Runner/TestSuiteSorter.php',
|
|
||||||
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
||||||
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
||||||
'TextUI/TestSuiteFilterProcessor.php',
|
'TextUI/TestSuiteFilterProcessor.php',
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<?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,23 +25,6 @@ final readonly class BootSubscribers implements Bootstrapper
|
|||||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||||
Subscribers\EnsureTeamCityEnabled::class,
|
Subscribers\EnsureTeamCityEnabled::class,
|
||||||
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
|
||||||
Subscribers\EnsureTiaStarts::class,
|
|
||||||
Subscribers\EnsureTiaEnds::class,
|
|
||||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnNoticeTriggered::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnPhpNoticeTriggered::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnDeprecationTriggered::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnPhpDeprecationTriggered::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnWarningTriggered::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnPhpWarningTriggered::class,
|
|
||||||
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,24 +7,16 @@ namespace Pest\Concerns;
|
|||||||
use Closure;
|
use Closure;
|
||||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
|
||||||
use Pest\Plugins\Tia\Collectors;
|
|
||||||
use Pest\Plugins\Tia\Enums\ReplayType;
|
|
||||||
use Pest\Plugins\Tia\Recorder;
|
|
||||||
use Pest\Preset;
|
use Pest\Preset;
|
||||||
use Pest\Support\ChainableClosure;
|
use Pest\Support\ChainableClosure;
|
||||||
use Pest\Support\Container;
|
|
||||||
use Pest\Support\ExceptionTrace;
|
use Pest\Support\ExceptionTrace;
|
||||||
use Pest\Support\Reflection;
|
use Pest\Support\Reflection;
|
||||||
use Pest\Support\Shell;
|
use Pest\Support\Shell;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Framework\AssertionFailedError;
|
|
||||||
use PHPUnit\Framework\Attributes\PostCondition;
|
use PHPUnit\Framework\Attributes\PostCondition;
|
||||||
use PHPUnit\Framework\IncompleteTest;
|
use PHPUnit\Framework\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,17 +75,6 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
public bool $__ran = false;
|
public bool $__ran = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* The active replay mode for this test, set in `setUp()` and checked
|
|
||||||
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
|
||||||
*/
|
|
||||||
private ReplayType $__replay = ReplayType::None;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The cached assertion count to replay, captured when entering replay mode.
|
|
||||||
*/
|
|
||||||
private int $__replayAssertions = 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The test's test closure.
|
* The test's test closure.
|
||||||
*/
|
*/
|
||||||
@@ -278,37 +259,8 @@ trait Testable
|
|||||||
self::$__latestIssues = $method->issues;
|
self::$__latestIssues = $method->issues;
|
||||||
self::$__latestPrs = $method->prs;
|
self::$__latestPrs = $method->prs;
|
||||||
|
|
||||||
/** @var Tia $tia */
|
|
||||||
$tia = Container::getInstance()->get(Tia::class);
|
|
||||||
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
|
||||||
$replay = ReplayType::fromStatus($status);
|
|
||||||
|
|
||||||
if ($replay !== ReplayType::None) {
|
|
||||||
assert($status !== null);
|
|
||||||
|
|
||||||
$this->__replay = $replay;
|
|
||||||
|
|
||||||
match ($replay) {
|
|
||||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
|
||||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
|
||||||
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
|
|
||||||
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
|
||||||
};
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$recorder = Container::getInstance()->get(Recorder::class);
|
|
||||||
assert($recorder instanceof Recorder);
|
|
||||||
|
|
||||||
if ($recorder->isActive()) {
|
|
||||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
Collectors::armAll($recorder);
|
|
||||||
|
|
||||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||||
|
|
||||||
if ($this->__beforeEach instanceof Closure) {
|
if ($this->__beforeEach instanceof Closure) {
|
||||||
@@ -318,13 +270,6 @@ trait Testable
|
|||||||
$this->__callClosure($beforeEach, $arguments);
|
$this->__callClosure($beforeEach, $arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
|
||||||
{
|
|
||||||
$this->__replay = $replay;
|
|
||||||
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
|
||||||
$this->__ran = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize test case properties from TestSuite.
|
* Initialize test case properties from TestSuite.
|
||||||
*/
|
*/
|
||||||
@@ -357,12 +302,6 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
protected function tearDown(...$arguments): void
|
protected function tearDown(...$arguments): void
|
||||||
{
|
{
|
||||||
if ($this->__replay !== ReplayType::None) {
|
|
||||||
TestSuite::getInstance()->test = null;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||||
|
|
||||||
if ($this->__afterEach instanceof Closure) {
|
if ($this->__afterEach instanceof Closure) {
|
||||||
@@ -388,16 +327,6 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __runTest(Closure $closure, ...$args): mixed
|
private function __runTest(Closure $closure, ...$args): mixed
|
||||||
{
|
{
|
||||||
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
|
|
||||||
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
|
||||||
$this->expectNotToPerformAssertions();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->addToAssertionCount($this->__replayAssertions);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$arguments = $this->__resolveTestArguments($args);
|
$arguments = $this->__resolveTestArguments($args);
|
||||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||||
|
|
||||||
@@ -435,15 +364,10 @@ trait Testable
|
|||||||
unset($this->{$property});
|
unset($this->{$property});
|
||||||
}
|
}
|
||||||
|
|
||||||
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
||||||
|
|
||||||
if ($outputBuffer->hasExpectation()) {
|
if ($hasOutputExpectation) {
|
||||||
ob_clean();
|
ob_clean();
|
||||||
|
|
||||||
Closure::bind(function (): void {
|
|
||||||
$this->expectedString = null;
|
|
||||||
$this->expectedRegularExpression = null;
|
|
||||||
}, $outputBuffer, OutputBuffer::class)();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->setUp();
|
$this->setUp();
|
||||||
@@ -459,9 +383,7 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __isExpectedException(Throwable $e): bool
|
private function __isExpectedException(Throwable $e): bool
|
||||||
{
|
{
|
||||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
||||||
|
|
||||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
|
||||||
|
|
||||||
$expectedClass = $read('expectedException');
|
$expectedClass = $read('expectedException');
|
||||||
|
|
||||||
@@ -469,19 +391,13 @@ trait Testable
|
|||||||
return $e instanceof $expectedClass;
|
return $e instanceof $expectedClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedMessage = $read('expectedMessage');
|
$expectedMessage = $read('expectedExceptionMessage');
|
||||||
|
|
||||||
if ($expectedMessage !== null) {
|
if ($expectedMessage !== null) {
|
||||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
$expectedCode = $read('expectedExceptionCode');
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
+3
-19
@@ -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,22 +119,6 @@ final readonly class Configuration
|
|||||||
return new Browser\Configuration;
|
return new Browser\Configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the evals configuration.
|
|
||||||
*/
|
|
||||||
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
|
||||||
{
|
|
||||||
return new Evals\Configuration; // @phpstan-ignore-line
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the TIA (Test Impact Analysis) configuration.
|
|
||||||
*/
|
|
||||||
public function tia(): Plugins\Tia\Configuration
|
|
||||||
{
|
|
||||||
return new Plugins\Tia\Configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxies calls to the uses method.
|
* Proxies calls to the uses method.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -59,15 +59,12 @@ final class Project
|
|||||||
/**
|
/**
|
||||||
* Sets the test project to GitLab.
|
* Sets the test project to GitLab.
|
||||||
*/
|
*/
|
||||||
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
public function gitlab(string $project): self
|
||||||
{
|
{
|
||||||
// Simple way to ensure only the host is used
|
$this->issues = "https://gitlab.com/{$project}/issues/%s";
|
||||||
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
|
||||||
|
|
||||||
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
$this->assignees = 'https://gitlab.com/%s';
|
||||||
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
|
|
||||||
|
|
||||||
$this->assignees = "https://{$hostname}/%s";
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Contracts;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
interface Restarter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
*/
|
|
||||||
public function maybeRestart(string $projectRoot, array $arguments): void;
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
|
||||||
use Pest\Contracts\Panicable;
|
|
||||||
use Pest\Support\View;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly string $headline,
|
|
||||||
private readonly string $hint,
|
|
||||||
private readonly bool $hasAnchor = false,
|
|
||||||
) {
|
|
||||||
parent::__construct($headline);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(OutputInterface $output): void
|
|
||||||
{
|
|
||||||
View::renderUsing($output);
|
|
||||||
|
|
||||||
if (! $this->hasAnchor) {
|
|
||||||
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
|
||||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
|
||||||
$output->writeln('');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->renderChild($output, $this->headline);
|
|
||||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
|
||||||
$output->writeln('');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exitCode(): int
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function renderChild(OutputInterface $output, string $text): void
|
|
||||||
{
|
|
||||||
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name, string $scope)
|
public function __construct(string $name, string $scope)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name)
|
public function __construct(string $name)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class DatasetProviderError extends RuntimeException
|
|
||||||
{
|
|
||||||
public function __construct(Throwable $previous)
|
|
||||||
{
|
|
||||||
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
|
||||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Creates a new Exception instance for the given class name.
|
|
||||||
*/
|
|
||||||
public static function fromClassName(string $filename, string $className): self
|
|
||||||
{
|
|
||||||
return new self(sprintf(
|
|
||||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
|
||||||
$filename,
|
|
||||||
$className,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new Exception instance for the given namespace.
|
|
||||||
*/
|
|
||||||
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
|
||||||
{
|
|
||||||
return new self(sprintf(
|
|
||||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
|
||||||
$filename,
|
|
||||||
$namespace,
|
|
||||||
$part,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,6 @@ final class MissingDependency extends InvalidArgumentException implements Except
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $feature, string $dependency)
|
public function __construct(string $feature, string $dependency)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The feature [%s] requires [%s].', $feature, $dependency));
|
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
|
||||||
use Pest\Contracts\Panicable;
|
|
||||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
|
||||||
{
|
|
||||||
public function render(OutputInterface $output): void
|
|
||||||
{
|
|
||||||
$output->writeln([
|
|
||||||
'',
|
|
||||||
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exitCode(): int
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName, string $description)
|
public function __construct(string $fileName, string $description)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $testCaseClass)
|
public function __construct(string $testCaseClass)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName)
|
public function __construct(string $fileName)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
|
||||||
use Pest\Contracts\Panicable;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class TiaRequiresCommit extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct(
|
|
||||||
'Tia mode requires a repository with at least one commit, so the baseline it records can be anchored to a revision.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(OutputInterface $output): void
|
|
||||||
{
|
|
||||||
$output->writeln([
|
|
||||||
'',
|
|
||||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires at least one commit.',
|
|
||||||
'',
|
|
||||||
' A baseline is anchored to the revision it was recorded at, and this repository',
|
|
||||||
' has none yet, so there is nothing to record against and nothing to compare a',
|
|
||||||
' later run to.',
|
|
||||||
'',
|
|
||||||
' Commit once, then run again:',
|
|
||||||
'',
|
|
||||||
' <fg=yellow>git add . && git commit -m "Initial commit"</>',
|
|
||||||
'',
|
|
||||||
' Runs without <fg=yellow>--tia</> are unaffected.',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exitCode(): int
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
|
||||||
use Pest\Contracts\Panicable;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class TiaRequiresDefaultBranch extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct(
|
|
||||||
'Tia mode could not determine the default branch every other branch falls back to reading.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(OutputInterface $output): void
|
|
||||||
{
|
|
||||||
$output->writeln([
|
|
||||||
'',
|
|
||||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode could not determine the default branch.',
|
|
||||||
'',
|
|
||||||
' It is the branch whose baseline every other branch falls back to reading, and',
|
|
||||||
' nothing in this checkout names it: the repository has a remote, but no',
|
|
||||||
' <fg=yellow>origin/HEAD</>, and no CI provider stated it either. Guessing would re-run the',
|
|
||||||
' whole suite on every new branch while reporting it as a cache hit.',
|
|
||||||
'',
|
|
||||||
' Name the branch in <fg=yellow>tests/Pest.php</>:',
|
|
||||||
'',
|
|
||||||
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
|
||||||
'',
|
|
||||||
' Or let git answer, once per clone:',
|
|
||||||
'',
|
|
||||||
' <fg=yellow>git remote set-head origin --auto</>',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exitCode(): int
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Exceptions;
|
|
||||||
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
|
||||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
|
||||||
use Pest\Contracts\Panicable;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class TiaRequiresRemote extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
|
||||||
{
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
parent::__construct(
|
|
||||||
'Tia mode requires a repository with a remote, so the default branch every other branch falls back to reading can be resolved.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(OutputInterface $output): void
|
|
||||||
{
|
|
||||||
$output->writeln([
|
|
||||||
'',
|
|
||||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires a repository with a remote.',
|
|
||||||
'',
|
|
||||||
' Without one there is no way to tell which branch is the default — the branch',
|
|
||||||
' whose baseline every other branch falls back to reading — so the first run on',
|
|
||||||
' each new branch would silently re-run the whole suite.',
|
|
||||||
'',
|
|
||||||
' Add a remote, or name the branch yourself in <fg=yellow>tests/Pest.php</>:',
|
|
||||||
'',
|
|
||||||
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
|
||||||
'',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exitCode(): int
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-15
@@ -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, ...$arguments);
|
var_dump($this->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -120,22 +120,16 @@ 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): never
|
public function dd(mixed ...$arguments): void
|
||||||
{
|
{
|
||||||
if (function_exists('dd')) {
|
if (function_exists('dd')) {
|
||||||
dd($this->value, ...$arguments);
|
dd($this->value, ...$arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
var_dump($this->value);
|
||||||
ob_start();
|
|
||||||
var_dump($this->value, ...$arguments);
|
|
||||||
$output = ob_get_clean();
|
|
||||||
|
|
||||||
throw new ExpectationFailedException($output);
|
|
||||||
}
|
|
||||||
|
|
||||||
var_dump($this->value, ...$arguments);
|
|
||||||
|
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
@@ -244,7 +238,7 @@ final class Expectation
|
|||||||
if ($callbacks[$index] instanceof Closure) {
|
if ($callbacks[$index] instanceof Closure) {
|
||||||
$callbacks[$index](new self($value), new self($key));
|
$callbacks[$index](new self($value), new self($key));
|
||||||
} else {
|
} else {
|
||||||
new self($value)->toEqual($callbacks[$index]);
|
(new self($value))->toEqual($callbacks[$index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||||
@@ -921,7 +915,15 @@ final class Expectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$this,
|
$this,
|
||||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
function (ObjectDescription $object) use ($interfaces): bool {
|
||||||
|
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')),
|
||||||
);
|
);
|
||||||
@@ -1136,8 +1138,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>
|
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||||
*/
|
*/
|
||||||
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>
|
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||||
*/
|
*/
|
||||||
public function __get(string $name): self
|
public function __get(string $name): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -576,7 +576,15 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$original,
|
$original,
|
||||||
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
function (ObjectDescription $object) use ($traits): bool {
|
||||||
|
foreach ($traits as $trait) {
|
||||||
|
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
"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')),
|
||||||
);
|
);
|
||||||
@@ -596,7 +604,15 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$original,
|
$original,
|
||||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
function (ObjectDescription $object) use ($interfaces): bool {
|
||||||
|
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')),
|
||||||
);
|
);
|
||||||
@@ -798,11 +814,13 @@ 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.',
|
||||||
$exporter->shortenedExport($this->original->value),
|
$toString($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 => $exporter->export($argument), $arguments)),
|
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,8 +852,8 @@ final readonly class OppositeExpectation
|
|||||||
$original,
|
$original,
|
||||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||||
|| ! $object->reflectionClass->isEnum()
|
|| ! $object->reflectionClass->isEnum()
|
||||||
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||||
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||||
'not to be '.$backingType.' backed enum',
|
'not to be '.$backingType.' backed enum',
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use Pest\Concerns;
|
|||||||
use Pest\Contracts\HasPrintableTestCaseName;
|
use Pest\Contracts\HasPrintableTestCaseName;
|
||||||
use Pest\Evaluators\Attributes;
|
use Pest\Evaluators\Attributes;
|
||||||
use Pest\Exceptions\DatasetMissing;
|
use Pest\Exceptions\DatasetMissing;
|
||||||
use Pest\Exceptions\InvalidTestClassName;
|
|
||||||
use Pest\Exceptions\ShouldNotHappen;
|
use Pest\Exceptions\ShouldNotHappen;
|
||||||
use Pest\Exceptions\TestAlreadyExist;
|
use Pest\Exceptions\TestAlreadyExist;
|
||||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||||
@@ -95,8 +94,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
$realpath = (string) realpath($filename);
|
$filename = str_replace('\\\\', '\\', addslashes((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);
|
||||||
|
|
||||||
@@ -139,16 +137,6 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
if (trim($className) === '') {
|
if (trim($className) === '') {
|
||||||
$className = 'InvalidTestName'.Str::random();
|
$className = 'InvalidTestName'.Str::random();
|
||||||
} elseif (! Str::isValidClassName($className)) {
|
|
||||||
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->namespace === null) {
|
|
||||||
foreach ($partsFQN as $partFQN) {
|
|
||||||
if (! Str::isValidIdentifier($partFQN)) {
|
|
||||||
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->attributes = [
|
$this->attributes = [
|
||||||
@@ -161,8 +149,6 @@ 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
|
||||||
@@ -172,7 +158,6 @@ 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;
|
||||||
|
|
||||||
@@ -181,7 +166,7 @@ final class TestCaseFactory
|
|||||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||||
$traitsCode
|
$traitsCode
|
||||||
|
|
||||||
public static \$__filename = $filenameLiteral;
|
private static \$__filename = '$filename';
|
||||||
|
|
||||||
$methodsCode
|
$methodsCode
|
||||||
}
|
}
|
||||||
@@ -212,7 +197,7 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$method->closure instanceof \Closure &&
|
$method->closure instanceof \Closure &&
|
||||||
new \ReflectionFunction($method->closure)->isStatic()
|
(new \ReflectionFunction($method->closure))->isStatic()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
throw new TestClosureMustNotBeStatic($method);
|
throw new TestClosureMustNotBeStatic($method);
|
||||||
|
|||||||
@@ -235,10 +235,6 @@ 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,
|
||||||
@@ -265,11 +261,7 @@ 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;
|
||||||
|
|||||||
+9
-33
@@ -27,13 +27,8 @@ use Whoops\Exception\Inspector;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Kernel
|
final readonly class Kernel
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Either the kernel is terminated or not.
|
|
||||||
*/
|
|
||||||
private bool $terminated = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Kernel bootstrappers.
|
* The Kernel bootstrappers.
|
||||||
*
|
*
|
||||||
@@ -41,8 +36,6 @@ final class Kernel
|
|||||||
*/
|
*/
|
||||||
private const array BOOTSTRAPPERS = [
|
private const array BOOTSTRAPPERS = [
|
||||||
Bootstrappers\BootOverrides::class,
|
Bootstrappers\BootOverrides::class,
|
||||||
Bootstrappers\BootPhpUnitConfiguration::class,
|
|
||||||
Plugins\Tia\Bootstrapper::class,
|
|
||||||
Bootstrappers\BootSubscribers::class,
|
Bootstrappers\BootSubscribers::class,
|
||||||
Bootstrappers\BootFiles::class,
|
Bootstrappers\BootFiles::class,
|
||||||
Bootstrappers\BootView::class,
|
Bootstrappers\BootView::class,
|
||||||
@@ -50,22 +43,15 @@ final class Kernel
|
|||||||
Bootstrappers\BootExcludeList::class,
|
Bootstrappers\BootExcludeList::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* The Kernel restarters — resolved and invoked from `bin/pest`
|
|
||||||
* before any other Pest class is touched, so the list is exposed
|
|
||||||
* on the Kernel rather than driven from `bin/pest` directly.
|
|
||||||
*
|
|
||||||
* @var array<int, class-string<Contracts\Restarter>>
|
|
||||||
*/
|
|
||||||
public const array RESTARTERS = [
|
|
||||||
Restarters\XdebugRestarter::class,
|
|
||||||
Restarters\PcovRestarter::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Kernel instance.
|
* Creates a new Kernel instance.
|
||||||
*/
|
*/
|
||||||
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
|
public function __construct(
|
||||||
|
private Application $application,
|
||||||
|
private OutputInterface $output,
|
||||||
|
) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boots the Kernel.
|
* Boots the Kernel.
|
||||||
@@ -126,13 +112,9 @@ final class Kernel
|
|||||||
$configuration = Registry::get();
|
$configuration = Registry::get();
|
||||||
$result = Facade::result();
|
$result = Facade::result();
|
||||||
|
|
||||||
$result = CallsAddsOutput::execute(
|
return CallsAddsOutput::execute(
|
||||||
Result::exitCode($configuration, $result),
|
Result::exitCode($configuration, $result),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->terminate();
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,12 +122,6 @@ final 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);
|
||||||
@@ -163,7 +139,7 @@ final 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_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -4,7 +4,6 @@ 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;
|
||||||
|
|
||||||
@@ -29,10 +28,6 @@ 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;
|
||||||
|
|
||||||
@@ -73,10 +68,6 @@ 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(
|
||||||
@@ -116,6 +107,7 @@ 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
-23
@@ -12,9 +12,12 @@ use PHPUnit\Event\Code\Test;
|
|||||||
use PHPUnit\Event\Code\TestMethod;
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
use PHPUnit\Event\Code\Throwable;
|
use PHPUnit\Event\Code\Throwable;
|
||||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
use PHPUnit\Event\Test\ConsideredRisky;
|
||||||
|
use PHPUnit\Event\Test\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;
|
||||||
@@ -249,29 +252,23 @@ final readonly class Converter
|
|||||||
...$result->testMarkedIncompleteEvents(),
|
...$result->testMarkedIncompleteEvents(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$notPassedTests = [];
|
$numberOfNotPassedTests = count(
|
||||||
|
array_unique(
|
||||||
|
array_map(
|
||||||
|
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||||
|
if ($event instanceof BeforeFirstTestMethodErrored
|
||||||
|
|| $event instanceof AfterLastTestMethodErrored) {
|
||||||
|
return $event->testClassName();
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($events as $event) {
|
return $this->getTestCaseLocation($event->test());
|
||||||
if ($event instanceof AfterLastTestMethodErrored) {
|
},
|
||||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
$events
|
||||||
continue;
|
)
|
||||||
}
|
)
|
||||||
if ($event instanceof AfterLastTestMethodFailed) {
|
);
|
||||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
|
|
||||||
$notPassedTests[] = $event->testClassName();
|
|
||||||
|
|
||||||
continue;
|
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||||
}
|
|
||||||
|
|
||||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
|
||||||
}
|
|
||||||
|
|
||||||
$numberOfPassedTests = $result->numberOfTestsRun()
|
|
||||||
- count(array_unique($notPassedTests))
|
|
||||||
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
|
||||||
|
|
||||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ 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,12 +241,6 @@ 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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-157
@@ -14,7 +14,6 @@ use InvalidArgumentException;
|
|||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
use Pest\Exceptions\InvalidExpectationValue;
|
use Pest\Exceptions\InvalidExpectationValue;
|
||||||
use Pest\Matchers\Any;
|
use Pest\Matchers\Any;
|
||||||
use Pest\Plugins\Snapshot;
|
|
||||||
use Pest\Support\Arr;
|
use Pest\Support\Arr;
|
||||||
use Pest\Support\Exporter;
|
use Pest\Support\Exporter;
|
||||||
use Pest\Support\NullClosure;
|
use Pest\Support\NullClosure;
|
||||||
@@ -852,31 +851,18 @@ final class Expectation
|
|||||||
default => InvalidExpectationValue::expected('array|object|string'),
|
default => InvalidExpectationValue::expected('array|object|string'),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (! $snapshots->has()) {
|
if ($snapshots->has()) {
|
||||||
|
[$filename, $content] = $snapshots->get();
|
||||||
|
|
||||||
|
Assert::assertSame(
|
||||||
|
strtr($content, ["\r\n" => "\n", "\r" => "\n"]),
|
||||||
|
strtr($string, ["\r\n" => "\n", "\r" => "\n"]),
|
||||||
|
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
$filename = $snapshots->save($string);
|
$filename = $snapshots->save($string);
|
||||||
|
|
||||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
|
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
|
||||||
} else {
|
|
||||||
[$filename, $content] = $snapshots->get();
|
|
||||||
|
|
||||||
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
|
|
||||||
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
|
|
||||||
|
|
||||||
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
|
|
||||||
$snapshots->save($string);
|
|
||||||
|
|
||||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
|
|
||||||
} else {
|
|
||||||
if (Snapshot::$updateSnapshots) {
|
|
||||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
|
|
||||||
}
|
|
||||||
|
|
||||||
Assert::assertSame(
|
|
||||||
$normalizedContent,
|
|
||||||
$normalizedString,
|
|
||||||
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -936,7 +922,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,7 +940,6 @@ 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);
|
||||||
@@ -1142,22 +1127,6 @@ 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
|
||||||
*
|
*
|
||||||
@@ -1171,22 +1140,6 @@ 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
|
||||||
*
|
*
|
||||||
@@ -1219,104 +1172,4 @@ 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 pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
* This extension must be registered BEFORE the peststan 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,7 +53,9 @@ 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 '5.0.4';
|
return '4.4.4';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testDirectory(string $file = ''): string
|
function testDirectory(string $file = ''): string
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
if (! $this->hasArgument('--parallel', $arguments)) {
|
||||||
return $this->pushArgument('--cache-result', $arguments);
|
return $this->pushArgument('--cache-result', $arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,11 @@ trait HandleArguments
|
|||||||
*/
|
*/
|
||||||
public function popArgument(string $argument, array $arguments): array
|
public function popArgument(string $argument, array $arguments): array
|
||||||
{
|
{
|
||||||
$key = array_search($argument, $arguments, true);
|
$arguments = array_flip($arguments);
|
||||||
|
|
||||||
while ($key !== false) {
|
unset($arguments[$argument]);
|
||||||
unset($arguments[$key]);
|
|
||||||
$key = array_search($argument, $arguments, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values($arguments);
|
return array_values(array_flip($arguments));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ 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';
|
||||||
@@ -79,9 +77,11 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
return false;
|
return false;
|
||||||
}))];
|
}))];
|
||||||
|
|
||||||
|
$originals = array_flip($originals);
|
||||||
foreach ($arguments as $argument) {
|
foreach ($arguments as $argument) {
|
||||||
$originals = $this->popArgument($argument, $originals);
|
unset($originals[$argument]);
|
||||||
}
|
}
|
||||||
|
$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);
|
||||||
|
|||||||
@@ -114,41 +114,6 @@ final readonly class Help implements HandlesArguments
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$content['Tia'] = [
|
|
||||||
[
|
|
||||||
'arg' => '--tia',
|
|
||||||
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--no-tia',
|
|
||||||
'desc' => 'Disable test impact analysis for this run',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--tia --fresh',
|
|
||||||
'desc' => 'Discard the recorded dependency graph and record it again',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--tia --filtered',
|
|
||||||
'desc' => 'Narrow the run to the affected test files only',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--tia --locally',
|
|
||||||
'desc' => 'Enable test impact analysis on local machines only',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--tia --baselined',
|
|
||||||
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--tia --refetch',
|
|
||||||
'desc' => 'Force a fresh fetch of the shared dependency graph',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'arg' => '--baseline',
|
|
||||||
'desc' => 'Output to standard output the test impact analysis storage directory',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$content['Execution'] = [...[
|
$content['Execution'] = [...[
|
||||||
[
|
[
|
||||||
'arg' => '--parallel',
|
'arg' => '--parallel',
|
||||||
@@ -158,10 +123,6 @@ final readonly class Help implements HandlesArguments
|
|||||||
'arg' => '--update-snapshots',
|
'arg' => '--update-snapshots',
|
||||||
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
|
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'arg' => '--update-shards',
|
|
||||||
'desc' => 'Update shards.json with test timing data for time-balanced sharding',
|
|
||||||
],
|
|
||||||
], ...$content['Execution']];
|
], ...$content['Execution']];
|
||||||
|
|
||||||
$content['Selection'] = [[
|
$content['Selection'] = [[
|
||||||
|
|||||||
@@ -178,7 +178,13 @@ final class Parallel implements HandlesArguments
|
|||||||
{
|
{
|
||||||
$arguments = new ArgvInput;
|
$arguments = new ArgvInput;
|
||||||
|
|
||||||
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
||||||
|
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ 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,14 +7,13 @@ namespace Pest\Plugins\Parallel\Paratest;
|
|||||||
use const DIRECTORY_SEPARATOR;
|
use const DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||||
|
use ParaTest\Coverage\CoverageMerger;
|
||||||
use ParaTest\JUnit\LogMerger;
|
use ParaTest\JUnit\LogMerger;
|
||||||
use ParaTest\JUnit\Writer;
|
use ParaTest\JUnit\Writer;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
use ParaTest\RunnerInterface;
|
use ParaTest\RunnerInterface;
|
||||||
use ParaTest\WrapperRunner\MissingResultsException;
|
|
||||||
use ParaTest\WrapperRunner\SuiteLoader;
|
use ParaTest\WrapperRunner\SuiteLoader;
|
||||||
use ParaTest\WrapperRunner\WrapperWorker;
|
use ParaTest\WrapperRunner\WrapperWorker;
|
||||||
use Pest\Plugins\Tia;
|
|
||||||
use Pest\Result;
|
use Pest\Result;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Event\Facade as EventFacade;
|
use PHPUnit\Event\Facade as EventFacade;
|
||||||
@@ -26,17 +25,11 @@ 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;
|
||||||
@@ -44,8 +37,6 @@ 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;
|
||||||
@@ -58,14 +49,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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,18 +83,6 @@ 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 = [];
|
||||||
|
|
||||||
@@ -156,7 +135,6 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
/** @var array<int, non-empty-string> $parameters */
|
/** @var array<int, non-empty-string> $parameters */
|
||||||
$parameters = $this->handleLaravelHerd($parameters);
|
$parameters = $this->handleLaravelHerd($parameters);
|
||||||
$parameters = $this->handleTia($parameters);
|
|
||||||
|
|
||||||
$parameters[] = $wrapper;
|
$parameters[] = $wrapper;
|
||||||
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||||
@@ -168,6 +146,7 @@ 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();
|
||||||
@@ -204,19 +183,6 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
return $parameters;
|
return $parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, non-empty-string> $parameters
|
|
||||||
* @return array<int, non-empty-string>
|
|
||||||
*/
|
|
||||||
private function handleTia(array $parameters): array
|
|
||||||
{
|
|
||||||
if (! Tia::recordsEdgesInWorkers()) {
|
|
||||||
return $parameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function startWorkers(): void
|
private function startWorkers(): void
|
||||||
{
|
{
|
||||||
for ($token = 1; $token <= $this->options->processes; $token++) {
|
for ($token = 1; $token <= $this->options->processes; $token++) {
|
||||||
@@ -247,7 +213,7 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$this->exitcode > 0
|
$this->exitcode > 0
|
||||||
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
&& $this->options->configuration->stopOnFailure()
|
||||||
) {
|
) {
|
||||||
$this->pending = [];
|
$this->pending = [];
|
||||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||||
@@ -262,18 +228,6 @@ 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,
|
||||||
@@ -321,15 +275,10 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
$worker->start();
|
$worker->start();
|
||||||
$this->batches[$token] = 0;
|
$this->batches[$token] = 0;
|
||||||
|
|
||||||
$this->statusFiles[] = $worker->statusFile;
|
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||||
$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;
|
||||||
}
|
}
|
||||||
@@ -362,20 +311,6 @@ 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;
|
||||||
@@ -410,20 +345,6 @@ 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()),
|
||||||
@@ -459,15 +380,8 @@ 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 in class')
|
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
||||||
)),
|
)),
|
||||||
$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(),
|
||||||
@@ -503,11 +417,8 @@ 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);
|
||||||
@@ -522,20 +433,6 @@ 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,
|
||||||
@@ -551,33 +448,10 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$coverageFiles = [];
|
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
||||||
foreach ($this->coverageFiles as $fileInfo) {
|
foreach ($this->coverageFiles as $coverageFile) {
|
||||||
$realPath = $fileInfo->getRealPath();
|
$coverageMerger->addCoverageFromFile($coverageFile);
|
||||||
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,7 +7,6 @@ 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;
|
||||||
@@ -148,20 +147,11 @@ 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);
|
||||||
|
|||||||
+14
-460
@@ -6,13 +6,7 @@ namespace Pest\Plugins;
|
|||||||
|
|
||||||
use Pest\Contracts\Plugins\AddsOutput;
|
use Pest\Contracts\Plugins\AddsOutput;
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
use Pest\Contracts\Plugins\Terminable;
|
|
||||||
use Pest\Exceptions\InvalidOption;
|
use Pest\Exceptions\InvalidOption;
|
||||||
use Pest\Subscribers\EnsureShardTimingFinished;
|
|
||||||
use Pest\Subscribers\EnsureShardTimingsAreCollected;
|
|
||||||
use Pest\Subscribers\EnsureShardTimingStarted;
|
|
||||||
use Pest\TestSuite;
|
|
||||||
use PHPUnit\Event;
|
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
@@ -21,19 +15,12 @@ use Symfony\Component\Process\Process;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Shard implements AddsOutput, HandlesArguments, Terminable
|
final class Shard implements AddsOutput, HandlesArguments
|
||||||
{
|
{
|
||||||
use Concerns\HandleArguments;
|
use Concerns\HandleArguments;
|
||||||
|
|
||||||
private const string SHARD_OPTION = 'shard';
|
private const string SHARD_OPTION = 'shard';
|
||||||
|
|
||||||
/**
|
|
||||||
* The maximum length allowed for the filter argument.
|
|
||||||
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
|
|
||||||
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
|
|
||||||
*/
|
|
||||||
private const int MAX_FILTER_LENGTH = 32768;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shard index and total number of shards.
|
* The shard index and total number of shards.
|
||||||
*
|
*
|
||||||
@@ -46,40 +33,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private static ?array $shard = null;
|
private static ?array $shard = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to update the shards.json file.
|
|
||||||
*/
|
|
||||||
private static bool $updateShards = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether time-balanced sharding was used.
|
|
||||||
*/
|
|
||||||
private static bool $timeBalanced = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the shards.json file is outdated.
|
|
||||||
*/
|
|
||||||
private static bool $shardsOutdated = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the test suite passed.
|
|
||||||
*/
|
|
||||||
private static bool $passed = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collected timings from workers or subscribers.
|
|
||||||
*
|
|
||||||
* @var array<string, float>|null
|
|
||||||
*/
|
|
||||||
private static ?array $collectedTimings = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The canonical list of test classes from --list-tests.
|
|
||||||
*
|
|
||||||
* @var list<string>|null
|
|
||||||
*/
|
|
||||||
private static ?array $knownTests = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Plugin instance.
|
* Creates a new Plugin instance.
|
||||||
*/
|
*/
|
||||||
@@ -94,19 +47,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
public function handleArguments(array $arguments): array
|
public function handleArguments(array $arguments): array
|
||||||
{
|
{
|
||||||
if ($this->hasArgument('--update-shards', $arguments)) {
|
|
||||||
return $this->handleUpdateShards($arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
|
|
||||||
self::$updateShards = true;
|
|
||||||
|
|
||||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
|
|
||||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
|
|
||||||
|
|
||||||
return $arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->hasArgument('--shard', $arguments)) {
|
if (! $this->hasArgument('--shard', $arguments)) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
@@ -123,25 +63,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
/** @phpstan-ignore-next-line */
|
/** @phpstan-ignore-next-line */
|
||||||
$tests = $this->allTests($arguments);
|
$tests = $this->allTests($arguments);
|
||||||
|
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||||
$timings = $this->loadShardsFile();
|
|
||||||
if ($timings !== null) {
|
|
||||||
$knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
|
|
||||||
$newTests = array_values(array_diff($tests, $knownTests));
|
|
||||||
|
|
||||||
$partitions = $this->partitionByTime($knownTests, $timings, $total);
|
|
||||||
|
|
||||||
foreach ($newTests as $i => $test) {
|
|
||||||
$partitions[$i % $total][] = $test;
|
|
||||||
}
|
|
||||||
|
|
||||||
$testsToRun = $partitions[$index - 1] ?? [];
|
|
||||||
self::$timeBalanced = true;
|
|
||||||
self::$shardsOutdated = $newTests !== [];
|
|
||||||
} else {
|
|
||||||
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
|
||||||
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
|
||||||
}
|
|
||||||
|
|
||||||
self::$shard = [
|
self::$shard = [
|
||||||
'index' => $index,
|
'index' => $index,
|
||||||
@@ -150,45 +72,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
'testsCount' => count($tests),
|
'testsCount' => count($tests),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($testsToRun === []) {
|
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||||
return $arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filter = $this->buildFilterArgument($testsToRun);
|
|
||||||
|
|
||||||
$this->ensureFilterLengthIsSafe($filter);
|
|
||||||
|
|
||||||
return [...$arguments, '--filter', $filter];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the --update-shards argument.
|
|
||||||
*
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function handleUpdateShards(array $arguments): array
|
|
||||||
{
|
|
||||||
if ($this->hasArgument('--shard', $arguments)) {
|
|
||||||
throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
|
|
||||||
}
|
|
||||||
|
|
||||||
$arguments = $this->popArgument('--update-shards', $arguments);
|
|
||||||
|
|
||||||
self::$updateShards = true;
|
|
||||||
|
|
||||||
/** @phpstan-ignore-next-line */
|
|
||||||
self::$knownTests = $this->allTests($arguments);
|
|
||||||
|
|
||||||
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
|
|
||||||
Parallel::setGlobal('UPDATE_SHARDS', true);
|
|
||||||
Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true));
|
|
||||||
} else {
|
|
||||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
|
|
||||||
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $arguments;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,14 +83,15 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function allTests(array $arguments): array
|
private function allTests(array $arguments): array
|
||||||
{
|
{
|
||||||
$command = $this->buildListTestsCommand(
|
$output = (new Process([
|
||||||
$arguments,
|
'php',
|
||||||
TestSuite::getInstance()->testPath,
|
...$this->removeParallelArguments($arguments),
|
||||||
);
|
'--list-tests',
|
||||||
|
]))->mustRun()->getOutput();
|
||||||
|
|
||||||
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
||||||
|
|
||||||
return $this->parseListTestsOutput($output);
|
return array_values(array_unique($matches[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,97 +100,15 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function removeParallelArguments(array $arguments): array
|
private function removeParallelArguments(array $arguments): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter(
|
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
||||||
$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(array $testsToRun): string
|
private function buildFilterArgument(mixed $testsToRun): string
|
||||||
{
|
{
|
||||||
if ($testsToRun === []) {
|
return addslashes(implode('|', $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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,22 +116,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
public function addOutput(int $exitCode): int
|
public function addOutput(int $exitCode): int
|
||||||
{
|
{
|
||||||
self::$passed = $exitCode === 0;
|
|
||||||
|
|
||||||
if (self::$updateShards && self::$passed && ! Parallel::isWorker()) {
|
|
||||||
self::$collectedTimings = $this->collectTimings();
|
|
||||||
|
|
||||||
$count = self::$knownTests !== null
|
|
||||||
? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
|
|
||||||
: count(self::$collectedTimings);
|
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
|
||||||
' <fg=gray>Shards:</> <fg=default>shards.json updated with timings for %d test class%s.</>',
|
|
||||||
$count,
|
|
||||||
$count === 1 ? '' : 'es',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::$shard === null) {
|
if (self::$shard === null) {
|
||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
@@ -341,250 +128,17 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
] = self::$shard;
|
] = self::$shard;
|
||||||
|
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d%s.',
|
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
|
||||||
$index,
|
$index,
|
||||||
$total,
|
$total,
|
||||||
$testsRan,
|
$testsRan,
|
||||||
$testsRan === 1 ? '' : 's',
|
$testsRan === 1 ? '' : 's',
|
||||||
$testsCount,
|
$testsCount,
|
||||||
self::$timeBalanced ? ' <fg=gray>(time-balanced)</>' : '',
|
|
||||||
));
|
));
|
||||||
|
|
||||||
if (self::$shardsOutdated) {
|
|
||||||
$this->output->writeln(' <fg=yellow;options=bold>WARN</> <fg=default>The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.</>');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminates the plugin.
|
|
||||||
*/
|
|
||||||
public function terminate(): void
|
|
||||||
{
|
|
||||||
if (! self::$updateShards) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Parallel::isWorker()) {
|
|
||||||
$this->writeWorkerTimings();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! self::$passed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timings = self::$collectedTimings ?? $this->collectTimings();
|
|
||||||
|
|
||||||
if ($timings === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->writeTimings($timings);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects timings from subscribers or worker temp files.
|
|
||||||
*
|
|
||||||
* @return array<string, float>
|
|
||||||
*/
|
|
||||||
private function collectTimings(): array
|
|
||||||
{
|
|
||||||
$runId = Parallel::getGlobal('SHARD_RUN_ID');
|
|
||||||
|
|
||||||
if (is_string($runId)) {
|
|
||||||
return $this->readWorkerTimings($runId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return EnsureShardTimingsAreCollected::timings();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes the current worker's timing data to a temp file.
|
|
||||||
*/
|
|
||||||
private function writeWorkerTimings(): void
|
|
||||||
{
|
|
||||||
$timings = EnsureShardTimingsAreCollected::timings();
|
|
||||||
|
|
||||||
if ($timings === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runId = Parallel::getGlobal('SHARD_RUN_ID');
|
|
||||||
|
|
||||||
if (! is_string($runId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';
|
|
||||||
|
|
||||||
file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads and merges timing data from all worker temp files.
|
|
||||||
*
|
|
||||||
* @return array<string, float>
|
|
||||||
*/
|
|
||||||
private function readWorkerTimings(string $runId): array
|
|
||||||
{
|
|
||||||
$pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
|
|
||||||
$files = glob($pattern);
|
|
||||||
|
|
||||||
if ($files === false || $files === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$merged = [];
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$contents = file_get_contents($file);
|
|
||||||
|
|
||||||
if ($contents === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$timings = json_decode($contents, true);
|
|
||||||
|
|
||||||
if (is_array($timings)) {
|
|
||||||
$merged = array_merge($merged, $timings);
|
|
||||||
}
|
|
||||||
|
|
||||||
unlink($file);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the path to shards.json.
|
|
||||||
*/
|
|
||||||
private function shardsPath(): string
|
|
||||||
{
|
|
||||||
$testSuite = TestSuite::getInstance();
|
|
||||||
|
|
||||||
return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the timings from shards.json.
|
|
||||||
*
|
|
||||||
* @return array<string, float>|null
|
|
||||||
*/
|
|
||||||
private function loadShardsFile(): ?array
|
|
||||||
{
|
|
||||||
$path = $this->shardsPath();
|
|
||||||
|
|
||||||
if (! file_exists($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contents = file_get_contents($path);
|
|
||||||
|
|
||||||
if ($contents === false) {
|
|
||||||
throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($contents, true);
|
|
||||||
|
|
||||||
if (! is_array($data) || ! isset($data['timings']) || ! is_array($data['timings'])) {
|
|
||||||
throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data['timings'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Partitions tests across shards using the LPT (Longest Processing Time) algorithm.
|
|
||||||
*
|
|
||||||
* @param list<string> $tests
|
|
||||||
* @param array<string, float> $timings
|
|
||||||
* @return list<list<string>>
|
|
||||||
*/
|
|
||||||
private function partitionByTime(array $tests, array $timings, int $total): array
|
|
||||||
{
|
|
||||||
$knownTimings = array_filter(
|
|
||||||
array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
|
|
||||||
fn (?float $t): bool => $t !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;
|
|
||||||
|
|
||||||
$testsWithTimings = array_map(
|
|
||||||
fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
|
|
||||||
$tests,
|
|
||||||
);
|
|
||||||
|
|
||||||
usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);
|
|
||||||
|
|
||||||
/** @var list<list<string>> */
|
|
||||||
$bins = array_fill(0, $total, []);
|
|
||||||
/** @var non-empty-list<float> */
|
|
||||||
$binTimes = array_fill(0, $total, 0.0);
|
|
||||||
|
|
||||||
foreach ($testsWithTimings as $item) {
|
|
||||||
$minIndex = array_search(min($binTimes), $binTimes, strict: true);
|
|
||||||
assert(is_int($minIndex));
|
|
||||||
|
|
||||||
$bins[$minIndex][] = $item['test'];
|
|
||||||
$binTimes[$minIndex] += $item['time'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bins;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the median of an array of floats.
|
|
||||||
*
|
|
||||||
* @param list<float> $values
|
|
||||||
*/
|
|
||||||
private function median(array $values): float
|
|
||||||
{
|
|
||||||
sort($values);
|
|
||||||
|
|
||||||
$count = count($values);
|
|
||||||
$middle = (int) floor($count / 2);
|
|
||||||
|
|
||||||
if ($count % 2 === 0) {
|
|
||||||
return ($values[$middle - 1] + $values[$middle]) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $values[$middle];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writes the timings to shards.json.
|
|
||||||
*
|
|
||||||
* @param array<string, float> $timings
|
|
||||||
*/
|
|
||||||
private function writeTimings(array $timings): void
|
|
||||||
{
|
|
||||||
$path = $this->shardsPath();
|
|
||||||
|
|
||||||
$directory = dirname($path);
|
|
||||||
if (! is_dir($directory)) {
|
|
||||||
mkdir($directory, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::$knownTests !== null) {
|
|
||||||
$knownSet = array_flip(self::$knownTests);
|
|
||||||
$timings = array_intersect_key($timings, $knownSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($timings);
|
|
||||||
|
|
||||||
$canonical = self::$knownTests ?? array_keys($timings);
|
|
||||||
sort($canonical);
|
|
||||||
|
|
||||||
file_put_contents($path, json_encode([
|
|
||||||
'timings' => $timings,
|
|
||||||
'checksum' => md5(implode("\n", $canonical)),
|
|
||||||
'updated_at' => date('c'),
|
|
||||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the shard information.
|
* Returns the shard information.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins;
|
namespace Pest\Plugins;
|
||||||
|
|
||||||
use Pest\Contracts\Plugins\HandlesArguments;
|
use Pest\Contracts\Plugins\HandlesArguments;
|
||||||
|
use Pest\Exceptions\InvalidOption;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,116 +15,21 @@ final class Snapshot implements HandlesArguments
|
|||||||
{
|
{
|
||||||
use Concerns\HandleArguments;
|
use Concerns\HandleArguments;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether snapshots should be updated on this run.
|
|
||||||
*/
|
|
||||||
public static bool $updateSnapshots = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*/
|
*/
|
||||||
public function handleArguments(array $arguments): array
|
public function handleArguments(array $arguments): array
|
||||||
{
|
{
|
||||||
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SNAPSHOTS') === true) {
|
|
||||||
self::$updateSnapshots = true;
|
|
||||||
|
|
||||||
return $arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->hasArgument('--update-snapshots', $arguments)) {
|
if (! $this->hasArgument('--update-snapshots', $arguments)) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$updateSnapshots = true;
|
if ($this->hasArgument('--parallel', $arguments)) {
|
||||||
|
throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.');
|
||||||
if ($this->isFullRun($arguments)) {
|
|
||||||
TestSuite::getInstance()->snapshots->flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
|
TestSuite::getInstance()->snapshots->flush();
|
||||||
Parallel::setGlobal('UPDATE_SNAPSHOTS', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->popArgument('--update-snapshots', $arguments);
|
return $this->popArgument('--update-snapshots', $arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Options that take a value as the next argument (rather than via "=value").
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const array FLAGS_WITH_VALUES = [
|
|
||||||
'--filter',
|
|
||||||
'--group',
|
|
||||||
'--exclude-group',
|
|
||||||
'--test-suffix',
|
|
||||||
'--covers',
|
|
||||||
'--uses',
|
|
||||||
'--cache-directory',
|
|
||||||
'--cache-result-file',
|
|
||||||
'--configuration',
|
|
||||||
'--colors',
|
|
||||||
'--test-directory',
|
|
||||||
'--bootstrap',
|
|
||||||
'--order-by',
|
|
||||||
'--random-order-seed',
|
|
||||||
'--log-junit',
|
|
||||||
'--log-teamcity',
|
|
||||||
'--log-events-text',
|
|
||||||
'--log-events-verbose-text',
|
|
||||||
'--coverage-clover',
|
|
||||||
'--coverage-cobertura',
|
|
||||||
'--coverage-crap4j',
|
|
||||||
'--coverage-html',
|
|
||||||
'--coverage-php',
|
|
||||||
'--coverage-text',
|
|
||||||
'--coverage-xml',
|
|
||||||
'--assignee',
|
|
||||||
'--issue',
|
|
||||||
'--ticket',
|
|
||||||
'--pr',
|
|
||||||
'--pull-request',
|
|
||||||
'--retry',
|
|
||||||
'--shard',
|
|
||||||
'--repeat',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the command targets the entire suite (no filter, no path).
|
|
||||||
*
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
*/
|
|
||||||
private function isFullRun(array $arguments): bool
|
|
||||||
{
|
|
||||||
if ($this->hasArgument('--filter', $arguments)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokens = array_slice($arguments, 1);
|
|
||||||
$skipNext = false;
|
|
||||||
|
|
||||||
foreach ($tokens as $arg) {
|
|
||||||
if ($skipNext) {
|
|
||||||
$skipNext = false;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($arg === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($arg[0] === '-') {
|
|
||||||
if (in_array($arg, self::FLAGS_WITH_VALUES, true)) {
|
|
||||||
$skipNext = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-2279
File diff suppressed because it is too large
Load Diff
@@ -1,621 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Exceptions\BaselineFetchFailed;
|
|
||||||
use Pest\Panic;
|
|
||||||
use Pest\Plugins\Tia;
|
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
|
||||||
use Pest\Support\View;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class BaselineSync
|
|
||||||
{
|
|
||||||
private const string WORKFLOW_FILE = 'tia-baseline.yml';
|
|
||||||
|
|
||||||
private const string ARTIFACT_NAME = 'pest-tia-baseline';
|
|
||||||
|
|
||||||
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
|
|
||||||
|
|
||||||
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
|
|
||||||
|
|
||||||
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
|
|
||||||
|
|
||||||
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
|
|
||||||
|
|
||||||
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
|
||||||
|
|
||||||
private const array DIAGNOSES = [
|
|
||||||
'network' => [
|
|
||||||
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
|
|
||||||
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
|
|
||||||
],
|
|
||||||
'gh-auth' => [
|
|
||||||
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
|
|
||||||
'message' => 'authentication failed — run `gh auth login` and retry.',
|
|
||||||
],
|
|
||||||
'rate-limit' => [
|
|
||||||
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
|
|
||||||
'message' => 'GitHub API rate limit hit — try again later.',
|
|
||||||
],
|
|
||||||
'not-found' => [
|
|
||||||
'pattern' => '/404|not found|repository not found/i',
|
|
||||||
'message' => 'workflow or artifact not found in repo.',
|
|
||||||
],
|
|
||||||
'forbidden' => [
|
|
||||||
'pattern' => '/403|forbidden|access denied/i',
|
|
||||||
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private State $state,
|
|
||||||
private OutputInterface $output,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private function renderBadge(string $type, string $content): void
|
|
||||||
{
|
|
||||||
View::render('components.badge', ['type' => $type, 'content' => $content]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function renderChild(string $text): void
|
|
||||||
{
|
|
||||||
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
|
|
||||||
{
|
|
||||||
$repo = $this->detectGitHubRepo($projectRoot);
|
|
||||||
|
|
||||||
if ($repo === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
|
||||||
$this->renderBadge('WARN', sprintf(
|
|
||||||
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
|
|
||||||
$this->formatDuration($remaining),
|
|
||||||
));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->download($repo, $projectRoot, $hasAnchor);
|
|
||||||
$payload = $result['payload'];
|
|
||||||
$failureKind = $result['failureKind'];
|
|
||||||
|
|
||||||
if ($payload === null) {
|
|
||||||
if ($failureKind === 'no-runs' || $failureKind === null) {
|
|
||||||
$this->startCooldown();
|
|
||||||
$this->emitPublishInstructions();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload['coverage'] !== null) {
|
|
||||||
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->clearCooldown();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function cooldownRemaining(): ?int
|
|
||||||
{
|
|
||||||
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
|
|
||||||
|
|
||||||
if ($raw === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($raw, true);
|
|
||||||
|
|
||||||
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining = $decoded['until'] - time();
|
|
||||||
|
|
||||||
return $remaining > 0 ? $remaining : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function startCooldown(): void
|
|
||||||
{
|
|
||||||
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
|
|
||||||
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function clearCooldown(): void
|
|
||||||
{
|
|
||||||
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatDuration(int $seconds): string
|
|
||||||
{
|
|
||||||
if ($seconds >= 3600) {
|
|
||||||
return (int) round($seconds / 3600).'h';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($seconds >= 60) {
|
|
||||||
return (int) round($seconds / 60).'m';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $seconds.'s';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emitPublishInstructions(): void
|
|
||||||
{
|
|
||||||
if ($this->isCi()) {
|
|
||||||
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
|
|
||||||
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isCi(): bool
|
|
||||||
{
|
|
||||||
return getenv('GITHUB_ACTIONS') === 'true'
|
|
||||||
|| getenv('GITLAB_CI') === 'true'
|
|
||||||
|| getenv('CIRCLECI') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function detectGitHubRepo(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
|
||||||
|
|
||||||
if (! is_file($gitConfig)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = @file_get_contents($gitConfig);
|
|
||||||
|
|
||||||
if ($content === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $match[1];
|
|
||||||
|
|
||||||
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
|
|
||||||
return $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
|
||||||
return $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
|
||||||
return $m[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
|
|
||||||
*/
|
|
||||||
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
|
|
||||||
{
|
|
||||||
$this->validateGhDependencies($hasAnchor);
|
|
||||||
|
|
||||||
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
|
|
||||||
|
|
||||||
if ($listError !== null) {
|
|
||||||
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
|
|
||||||
|
|
||||||
$this->renderBadge('WARN', sprintf(
|
|
||||||
'Failed to query baseline runs — %s',
|
|
||||||
$listError['message'],
|
|
||||||
));
|
|
||||||
|
|
||||||
return ['payload' => null, 'failureKind' => $listError['kind']];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($runId === null) {
|
|
||||||
return ['payload' => null, 'failureKind' => 'no-runs'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
|
|
||||||
|
|
||||||
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
|
|
||||||
@touch($runCacheDir);
|
|
||||||
|
|
||||||
$this->renderChild(sprintf(
|
|
||||||
'Using cached baseline from %s (run %s).',
|
|
||||||
$repo,
|
|
||||||
$runId,
|
|
||||||
));
|
|
||||||
|
|
||||||
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
|
|
||||||
return ['payload' => null, 'failureKind' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
|
|
||||||
|
|
||||||
if (! $download['success']) {
|
|
||||||
return ['payload' => null, 'failureKind' => $download['failureKind']];
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
|
|
||||||
|
|
||||||
$this->trimDownloadCache($projectRoot);
|
|
||||||
|
|
||||||
return ['payload' => $payload, 'failureKind' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{kind: string, message: string} $diagnosis
|
|
||||||
*/
|
|
||||||
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
|
|
||||||
{
|
|
||||||
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Panic::with(new BaselineFetchFailed(
|
|
||||||
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
|
|
||||||
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
|
|
||||||
$hasAnchor,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validateGhDependencies(bool $hasAnchor): void
|
|
||||||
{
|
|
||||||
if (! $this->commandExists('gh')) {
|
|
||||||
Panic::with(new BaselineFetchFailed(
|
|
||||||
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
|
||||||
'Install it from https://cli.github.com.',
|
|
||||||
$hasAnchor,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->ghAuthenticated()) {
|
|
||||||
Panic::with(new BaselineFetchFailed(
|
|
||||||
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
|
||||||
'Run `gh auth login` and retry.',
|
|
||||||
$hasAnchor,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{success: bool, failureKind: ?string}
|
|
||||||
*/
|
|
||||||
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
|
|
||||||
{
|
|
||||||
$artifactSize = $this->artifactSize($repo, $runId);
|
|
||||||
|
|
||||||
$this->output->writeln('');
|
|
||||||
$this->renderChild($artifactSize !== null
|
|
||||||
? sprintf(
|
|
||||||
'Downloading TIA baseline (%s) from %s…',
|
|
||||||
$this->formatSize($artifactSize),
|
|
||||||
$repo,
|
|
||||||
)
|
|
||||||
: sprintf(
|
|
||||||
'Downloading TIA baseline from %s…',
|
|
||||||
$repo,
|
|
||||||
));
|
|
||||||
|
|
||||||
$process = new Process([
|
|
||||||
'gh', 'run', 'download', $runId,
|
|
||||||
'-R', $repo,
|
|
||||||
'-n', self::ARTIFACT_NAME,
|
|
||||||
'-D', $runCacheDir,
|
|
||||||
]);
|
|
||||||
$process->setTimeout(900.0);
|
|
||||||
$process->start();
|
|
||||||
|
|
||||||
$startedAt = microtime(true);
|
|
||||||
$tick = 0;
|
|
||||||
|
|
||||||
while ($process->isRunning()) {
|
|
||||||
$this->renderDownloadProgress($startedAt, $tick++);
|
|
||||||
usleep(120_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$process->wait();
|
|
||||||
$this->clearProgressLine();
|
|
||||||
|
|
||||||
if ($process->isSuccessful()) {
|
|
||||||
return ['success' => true, 'failureKind' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->cleanup($runCacheDir);
|
|
||||||
|
|
||||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
|
||||||
|
|
||||||
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
|
|
||||||
|
|
||||||
$this->renderBadge('WARN', sprintf(
|
|
||||||
'Baseline download failed — %s',
|
|
||||||
$diagnosis['message'],
|
|
||||||
));
|
|
||||||
|
|
||||||
return ['success' => false, 'failureKind' => $diagnosis['kind']];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
|
|
||||||
*/
|
|
||||||
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
|
|
||||||
{
|
|
||||||
$payload = $this->readArtifact($runCacheDir);
|
|
||||||
|
|
||||||
if ($payload === null) {
|
|
||||||
$this->cleanup($runCacheDir);
|
|
||||||
|
|
||||||
Panic::with(new BaselineFetchFailed(
|
|
||||||
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
|
||||||
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
|
||||||
$hasAnchor,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function artifactSize(string $repo, string $runId): ?int
|
|
||||||
{
|
|
||||||
$process = new Process([
|
|
||||||
'gh', 'api',
|
|
||||||
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
|
||||||
'--jq', sprintf(
|
|
||||||
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
|
|
||||||
self::ARTIFACT_NAME,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
$process->setTimeout(30.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$size = trim($process->getOutput());
|
|
||||||
|
|
||||||
return is_numeric($size) ? (int) $size : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function renderDownloadProgress(float $startedAt, int $tick): void
|
|
||||||
{
|
|
||||||
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
||||||
|
|
||||||
$elapsed = max(0.0, microtime(true) - $startedAt);
|
|
||||||
$frame = $frames[$tick % count($frames)];
|
|
||||||
|
|
||||||
$this->output->write(sprintf(
|
|
||||||
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
|
|
||||||
$frame,
|
|
||||||
$elapsed,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function clearProgressLine(): void
|
|
||||||
{
|
|
||||||
$this->output->write("\r\033[K");
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dirSize(string $dir): int
|
|
||||||
{
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** @var \SplFileInfo $entry */
|
|
||||||
foreach ($iterator as $entry) {
|
|
||||||
if ($entry->isFile()) {
|
|
||||||
$total += $entry->getSize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
|
|
||||||
*/
|
|
||||||
private function readArtifact(string $dir): ?array
|
|
||||||
{
|
|
||||||
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
|
|
||||||
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
|
|
||||||
|
|
||||||
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
|
|
||||||
|
|
||||||
if ($graph === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'graph' => $graph,
|
|
||||||
'coverage' => $coverage === false ? null : $coverage,
|
|
||||||
'sizeOnDisk' => $this->dirSize($dir),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function downloadCacheDir(string $projectRoot): string
|
|
||||||
{
|
|
||||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function safeRunId(string $runId): string
|
|
||||||
{
|
|
||||||
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
|
||||||
|
|
||||||
return $sanitised === '' ? 'unknown' : $sanitised;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function trimDownloadCache(string $projectRoot): void
|
|
||||||
{
|
|
||||||
$root = $this->downloadCacheDir($projectRoot);
|
|
||||||
|
|
||||||
if (! is_dir($root)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entries = @scandir($root);
|
|
||||||
|
|
||||||
if ($entries === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = [];
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if (in_array($entry, ['.', '..'], true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
|
||||||
|
|
||||||
if (! is_dir($path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$mtime = @filemtime($path);
|
|
||||||
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
usort(
|
|
||||||
$candidates,
|
|
||||||
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
|
|
||||||
$this->cleanup($stale['path']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
|
|
||||||
*/
|
|
||||||
private function latestSuccessfulRunIdWithError(string $repo): array
|
|
||||||
{
|
|
||||||
$process = new Process([
|
|
||||||
'gh', 'run', 'list',
|
|
||||||
'-R', $repo,
|
|
||||||
'--workflow', self::WORKFLOW_FILE,
|
|
||||||
'--status', 'success',
|
|
||||||
'--limit', '1',
|
|
||||||
'--json', 'databaseId',
|
|
||||||
'--jq', '.[0].databaseId // empty',
|
|
||||||
]);
|
|
||||||
$process->setTimeout(30.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
|
|
||||||
}
|
|
||||||
|
|
||||||
$runId = trim($process->getOutput());
|
|
||||||
|
|
||||||
return [$runId === '' ? null : $runId, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ghAuthenticated(): bool
|
|
||||||
{
|
|
||||||
$process = new Process(['gh', 'auth', 'status']);
|
|
||||||
$process->setTimeout(10.0);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
return $process->isSuccessful();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{kind: string, message: string}
|
|
||||||
*/
|
|
||||||
private function classifyGhError(string $output): array
|
|
||||||
{
|
|
||||||
$output = trim($output);
|
|
||||||
|
|
||||||
if ($output === '') {
|
|
||||||
return ['kind' => 'unknown', 'message' => 'unknown error'];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::DIAGNOSES as $kind => $diagnosis) {
|
|
||||||
if (preg_match($diagnosis['pattern'], $output) === 1) {
|
|
||||||
return ['kind' => $kind, 'message' => $diagnosis['message']];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function commandExists(string $cmd): bool
|
|
||||||
{
|
|
||||||
$process = new Process(['which', $cmd]);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
return $process->isSuccessful();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function cleanup(string $dir): void
|
|
||||||
{
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** @var \SplFileInfo $entry */
|
|
||||||
foreach ($iterator as $entry) {
|
|
||||||
if ($entry->isDir()) {
|
|
||||||
@rmdir($entry->getPathname());
|
|
||||||
} else {
|
|
||||||
@unlink($entry->getPathname());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@rmdir($dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatSize(int $bytes): string
|
|
||||||
{
|
|
||||||
if ($bytes >= 1024 * 1024) {
|
|
||||||
return sprintf('%.1f MB', $bytes / 1024 / 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($bytes >= 1024) {
|
|
||||||
return sprintf('%.1f KB', $bytes / 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bytes.' B';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Contracts\Bootstrapper as BootstrapperContract;
|
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
|
||||||
use Pest\Support\Container;
|
|
||||||
use Pest\TestSuite;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class Bootstrapper implements BootstrapperContract
|
|
||||||
{
|
|
||||||
public function __construct(private Container $container) {}
|
|
||||||
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
$testSuite = $this->container->get(TestSuite::class);
|
|
||||||
assert($testSuite instanceof TestSuite);
|
|
||||||
|
|
||||||
$tempDir = Storage::tempDir($testSuite->rootPath);
|
|
||||||
|
|
||||||
$this->container->add(State::class, new FileState($tempDir));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Exceptions\MissingDependency;
|
|
||||||
use Pest\Support\Git;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class ChangedFiles
|
|
||||||
{
|
|
||||||
private Git $git;
|
|
||||||
|
|
||||||
public function __construct(private string $projectRoot)
|
|
||||||
{
|
|
||||||
$this->git = new Git($projectRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $files project-relative paths.
|
|
||||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
|
||||||
{
|
|
||||||
if ($lastRunTree === []) {
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = array_fill_keys($files, true);
|
|
||||||
|
|
||||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
|
||||||
$candidates[$snapshotted] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining = [];
|
|
||||||
|
|
||||||
foreach (array_keys($candidates) as $file) {
|
|
||||||
$snapshot = $lastRunTree[$file] ?? null;
|
|
||||||
$current = $this->currentHash($file);
|
|
||||||
|
|
||||||
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
|
||||||
$remaining[] = $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentHash(string $relativePath): ?string
|
|
||||||
{
|
|
||||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
|
|
||||||
|
|
||||||
if (! is_file($absolute)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = ContentHash::of($absolute);
|
|
||||||
|
|
||||||
return $hash === false ? null : $hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $files
|
|
||||||
* @return array<string, string> path → xxh128 content hash
|
|
||||||
*/
|
|
||||||
public function snapshotTree(array $files): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
|
||||||
|
|
||||||
if (! is_file($absolute)) {
|
|
||||||
$out[$file] = '';
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = ContentHash::of($absolute);
|
|
||||||
|
|
||||||
if ($hash !== false) {
|
|
||||||
$out[$file] = $hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
|
||||||
*/
|
|
||||||
public function since(?string $sha): ?array
|
|
||||||
{
|
|
||||||
$files = [];
|
|
||||||
|
|
||||||
if ($sha !== null && $sha !== '') {
|
|
||||||
if (! $this->shaIsReachable($sha)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = array_merge($files, $this->diffSinceSha($sha));
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = array_merge($files, $this->workingTreeChanges());
|
|
||||||
|
|
||||||
$unique = [];
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
if ($file === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$unique[$file] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = array_keys($this->filterIgnored($unique));
|
|
||||||
|
|
||||||
if ($sha !== null && $sha !== '') {
|
|
||||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $files
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function filterBehaviourallyUnchanged(array $files, string $sha): array
|
|
||||||
{
|
|
||||||
$remaining = [];
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$currentHash = $this->currentHash($file);
|
|
||||||
|
|
||||||
if ($currentHash === null) {
|
|
||||||
$remaining[] = $file;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$baselineContent = $this->contentAtSha($sha, $file);
|
|
||||||
|
|
||||||
if ($baselineContent === null) {
|
|
||||||
$remaining[] = $file;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
|
|
||||||
$remaining[] = $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function contentAtSha(string $sha, string $path): ?string
|
|
||||||
{
|
|
||||||
return $this->git->show($sha, $path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, true> $candidates
|
|
||||||
* @return array<string, true>
|
|
||||||
*/
|
|
||||||
private function filterIgnored(array $candidates): array
|
|
||||||
{
|
|
||||||
if ($candidates === []) {
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->git->result(
|
|
||||||
['check-ignore', '--no-index', '-z', '--stdin'],
|
|
||||||
implode("\x00", array_keys($candidates)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// `check-ignore` exits 1 when nothing matched — that is not a failure.
|
|
||||||
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = $result['output'];
|
|
||||||
|
|
||||||
if ($output === '') {
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
|
|
||||||
if ($ignored !== '') {
|
|
||||||
unset($candidates[$ignored]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentBranch(): ?string
|
|
||||||
{
|
|
||||||
$output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
||||||
|
|
||||||
if ($output === null) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
$branch = trim($output);
|
|
||||||
|
|
||||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function defaultBranch(): ?string
|
|
||||||
{
|
|
||||||
$head = $this->git->output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
||||||
|
|
||||||
if ($head !== null) {
|
|
||||||
$branch = preg_replace('#^origin/#', '', $head);
|
|
||||||
|
|
||||||
if (is_string($branch) && $branch !== '') {
|
|
||||||
return $branch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$configured = $this->git->output(['config', '--get', 'init.defaultBranch']);
|
|
||||||
|
|
||||||
if ($configured === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$exists = $this->git->hasRef('refs/heads/'.$configured)
|
|
||||||
|| $this->git->hasRef('refs/remotes/origin/'.$configured);
|
|
||||||
|
|
||||||
return $exists ? $configured : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>|null
|
|
||||||
*/
|
|
||||||
public function branchNames(): ?array
|
|
||||||
{
|
|
||||||
$output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
|
|
||||||
|
|
||||||
if ($output === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$names = [];
|
|
||||||
|
|
||||||
foreach ($this->splitLines($output) as $ref) {
|
|
||||||
if (str_starts_with($ref, 'refs/heads/')) {
|
|
||||||
$names[substr($ref, strlen('refs/heads/'))] = true;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! str_starts_with($ref, 'refs/remotes/')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tail = substr($ref, strlen('refs/remotes/'));
|
|
||||||
$slash = strpos($tail, '/');
|
|
||||||
|
|
||||||
if ($slash === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$branch = substr($tail, $slash + 1);
|
|
||||||
|
|
||||||
if ($branch !== '' && $branch !== 'HEAD') {
|
|
||||||
$names[$branch] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys($names);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasRemote(): bool
|
|
||||||
{
|
|
||||||
return $this->git->hasRemote();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isRepository(): bool
|
|
||||||
{
|
|
||||||
return $this->git->isRepository();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasCommits(): bool
|
|
||||||
{
|
|
||||||
return $this->git->hasCommits();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Working-tree scans get a longer leash than metadata queries — on a large
|
|
||||||
* repository with a cold cache, `status` and `diff` are not instant.
|
|
||||||
*/
|
|
||||||
private function scan(): Git
|
|
||||||
{
|
|
||||||
return $this->git->withTimeout(60.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function shaIsReachable(string $sha): bool
|
|
||||||
{
|
|
||||||
return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function diffSinceSha(string $sha): array
|
|
||||||
{
|
|
||||||
$output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
|
|
||||||
|
|
||||||
if ($output === null) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->splitLines($output);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function workingTreeChanges(): array
|
|
||||||
{
|
|
||||||
$output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
||||||
|
|
||||||
if ($output === null) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($output === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$records = explode("\x00", rtrim($output, "\x00"));
|
|
||||||
$files = [];
|
|
||||||
$count = count($records);
|
|
||||||
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
|
||||||
$record = $records[$i];
|
|
||||||
|
|
||||||
if (strlen($record) < 4) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = substr($record, 0, 2);
|
|
||||||
$path = substr($record, 3);
|
|
||||||
|
|
||||||
if ($status[0] === 'R' || $status[0] === 'C') {
|
|
||||||
$files[] = $path;
|
|
||||||
|
|
||||||
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
|
|
||||||
$files[] = $records[$i + 1];
|
|
||||||
$i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$files[] = $path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentSha(): ?string
|
|
||||||
{
|
|
||||||
$output = $this->git->raw(['rev-parse', 'HEAD']);
|
|
||||||
|
|
||||||
if ($output === null) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
$sha = trim($output);
|
|
||||||
|
|
||||||
return $sha === '' ? null : $sha;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function splitLines(string $output): array
|
|
||||||
{
|
|
||||||
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
|
|
||||||
|
|
||||||
return $lines === false ? [] : $lines;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\Ci;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class CiDefaultBranch
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<int, class-string<Ci>>
|
|
||||||
*/
|
|
||||||
private const array CIS = [
|
|
||||||
Cis\GitLab::class,
|
|
||||||
Cis\GitHub::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
public static function detect(): ?string
|
|
||||||
{
|
|
||||||
foreach (self::CIS as $class) {
|
|
||||||
$branch = (new $class)->defaultBranch();
|
|
||||||
|
|
||||||
if ($branch !== null) {
|
|
||||||
return $branch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Cis\Concerns;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
trait ReadsEnvironment
|
|
||||||
{
|
|
||||||
private function environment(string $name): ?string
|
|
||||||
{
|
|
||||||
$value = getenv($name);
|
|
||||||
|
|
||||||
if (! is_string($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
return $value === '' ? null : $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Cis;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
|
|
||||||
use Pest\Plugins\Tia\Contracts\Ci;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class GitHub implements Ci
|
|
||||||
{
|
|
||||||
use ReadsEnvironment;
|
|
||||||
|
|
||||||
public function defaultBranch(): ?string
|
|
||||||
{
|
|
||||||
$path = $this->environment('GITHUB_EVENT_PATH');
|
|
||||||
|
|
||||||
if ($path === null || ! is_file($path) || ! is_readable($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contents = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($contents === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = json_decode($contents, true);
|
|
||||||
|
|
||||||
if (! is_array($payload) || ! is_array($payload['repository'] ?? null)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$branch = $payload['repository']['default_branch'] ?? null;
|
|
||||||
|
|
||||||
return is_string($branch) && $branch !== '' ? $branch : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Cis;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
|
|
||||||
use Pest\Plugins\Tia\Contracts\Ci;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class GitLab implements Ci
|
|
||||||
{
|
|
||||||
use ReadsEnvironment;
|
|
||||||
|
|
||||||
public function defaultBranch(): ?string
|
|
||||||
{
|
|
||||||
return $this->environment('CI_DEFAULT_BRANCH');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Edges\BladeEdges;
|
|
||||||
use Pest\Plugins\Tia\Edges\InertiaEdges;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class Collectors
|
|
||||||
{
|
|
||||||
/** @var list<class-string> */
|
|
||||||
private const array COLLECTORS = [
|
|
||||||
BladeEdges::class,
|
|
||||||
TableTracker::class,
|
|
||||||
InertiaEdges::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
public static function armAll(Recorder $recorder): void
|
|
||||||
{
|
|
||||||
foreach (self::COLLECTORS as $collector) {
|
|
||||||
$collector::arm($recorder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Support\Container;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class Configuration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function always(): self
|
|
||||||
{
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
|
||||||
$watchPatterns->markEnabled();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function locally(): self
|
|
||||||
{
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
|
||||||
$watchPatterns->markEnabled();
|
|
||||||
$watchPatterns->markLocally();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function filtered(): self
|
|
||||||
{
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
|
||||||
$watchPatterns->markFiltered();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function baselined(): self
|
|
||||||
{
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
|
||||||
$watchPatterns->markBaselined();
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function defaultBranch(string $branch): self
|
|
||||||
{
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
|
||||||
$watchPatterns->setDefaultBranch($branch);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $patterns glob → project-relative test dir
|
|
||||||
* @return $this
|
|
||||||
*/
|
|
||||||
public function watch(array $patterns): self
|
|
||||||
{
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
|
||||||
$watchPatterns->add($patterns);
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class ContentHash
|
|
||||||
{
|
|
||||||
public static function of(string $absolute): string|false
|
|
||||||
{
|
|
||||||
$raw = @file_get_contents($absolute);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::ofContent($absolute, $raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function ofContent(string $path, string $raw): string
|
|
||||||
{
|
|
||||||
$lower = strtolower($path);
|
|
||||||
|
|
||||||
if (str_ends_with($lower, '.blade.php')) {
|
|
||||||
return self::hashBladeContent($raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_ends_with($lower, '.php')) {
|
|
||||||
return self::hashPhpContent($raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['.vue', '.tsx', '.jsx', '.svelte', '.ts', '.js', '.mjs', '.cjs', '.mts'] as $extension) {
|
|
||||||
if (str_ends_with($lower, $extension)) {
|
|
||||||
return self::hashJsContent($raw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash('xxh128', $raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hashPhpContent(string $raw): string
|
|
||||||
{
|
|
||||||
$tokens = @token_get_all($raw);
|
|
||||||
|
|
||||||
if ($tokens === []) {
|
|
||||||
return hash('xxh128', $raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalised = '';
|
|
||||||
|
|
||||||
foreach ($tokens as $token) {
|
|
||||||
if (is_array($token)) {
|
|
||||||
if ($token[0] === T_WHITESPACE) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($token[0] === T_COMMENT) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($token[0] === T_DOC_COMMENT) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$normalised .= $token[1];
|
|
||||||
} else {
|
|
||||||
$normalised .= $token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash('xxh128', $normalised);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hashBladeContent(string $raw): string
|
|
||||||
{
|
|
||||||
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
|
||||||
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
|
||||||
|
|
||||||
return hash('xxh128', trim($stripped));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hashJsContent(string $raw): string
|
|
||||||
{
|
|
||||||
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
|
|
||||||
$stripped = preg_replace('/^\s*\/\*.*?\*\/\s*$/sm', '', $stripped) ?? $stripped;
|
|
||||||
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
|
||||||
|
|
||||||
return hash('xxh128', trim($stripped));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Contracts;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
interface Ci
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The default branch advertised by this CI, or `null` when the run
|
|
||||||
* does not happen on it — or when it exposes no such information.
|
|
||||||
*/
|
|
||||||
public function defaultBranch(): ?string;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Contracts;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
interface State
|
|
||||||
{
|
|
||||||
public function read(string $key): ?string;
|
|
||||||
|
|
||||||
public function write(string $key, string $content): bool;
|
|
||||||
|
|
||||||
public function delete(string $key): bool;
|
|
||||||
|
|
||||||
public function exists(string $key): bool;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function keysWithPrefix(string $prefix): array;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Contracts;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
interface WatchDefault
|
|
||||||
{
|
|
||||||
public function applicable(): bool;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
|
|
||||||
*/
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array;
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class CoverageCollector
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string, string|null>
|
|
||||||
*/
|
|
||||||
private array $classFileCache = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, string>>
|
|
||||||
*/
|
|
||||||
public function perTestFiles(): array
|
|
||||||
{
|
|
||||||
if (! PhpUnitCodeCoverage::instance()->isActive()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$lineCoverage = PhpUnitCodeCoverage::instance()
|
|
||||||
->codeCoverage()
|
|
||||||
->getData()
|
|
||||||
->lineCoverage();
|
|
||||||
} catch (Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, array<string, true>> $edges */
|
|
||||||
$edges = [];
|
|
||||||
|
|
||||||
foreach ($lineCoverage as $sourceFile => $lines) {
|
|
||||||
$testIds = [];
|
|
||||||
|
|
||||||
foreach ($lines as $hits) {
|
|
||||||
if ($hits === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($hits as $id) {
|
|
||||||
$testIds[$id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (array_keys($testIds) as $testId) {
|
|
||||||
$testFile = $this->testIdToFile($testId);
|
|
||||||
|
|
||||||
if ($testFile === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$edges[$testFile][$sourceFile] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($edges as $testFile => $sources) {
|
|
||||||
$out[$testFile] = array_keys($sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reset(): void
|
|
||||||
{
|
|
||||||
$this->classFileCache = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function testIdToFile(string $testId): ?string
|
|
||||||
{
|
|
||||||
$hash = strpos($testId, '#');
|
|
||||||
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
|
|
||||||
|
|
||||||
if (! str_contains($identifier, '::')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$className] = explode('::', $identifier, 2);
|
|
||||||
|
|
||||||
if (array_key_exists($className, $this->classFileCache)) {
|
|
||||||
return $this->classFileCache[$className];
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $this->resolveClassFile($className);
|
|
||||||
$this->classFileCache[$className] = $file;
|
|
||||||
|
|
||||||
return $file;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveClassFile(string $className): ?string
|
|
||||||
{
|
|
||||||
if (! class_exists($className, false)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
|
||||||
|
|
||||||
return $className::$__filename;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia;
|
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
|
||||||
use Pest\Support\Container;
|
|
||||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
|
||||||
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
|
||||||
use SebastianBergmann\CodeCoverage\Filter;
|
|
||||||
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class CoverageMerger
|
|
||||||
{
|
|
||||||
public static function applyIfMarked(string $reportPath): void
|
|
||||||
{
|
|
||||||
$state = self::state();
|
|
||||||
|
|
||||||
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$state->delete(Tia::KEY_COVERAGE_MARKER);
|
|
||||||
|
|
||||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
|
||||||
|
|
||||||
if ($cachedBytes === null) {
|
|
||||||
$current = self::requireCoverage($reportPath);
|
|
||||||
|
|
||||||
if ($current instanceof CodeCoverage) {
|
|
||||||
self::primeUncoveredFiles($current);
|
|
||||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = self::decompress($cachedBytes);
|
|
||||||
|
|
||||||
if ($decoded === null) {
|
|
||||||
$state->delete(Tia::KEY_COVERAGE_CACHE);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cached = self::unserializeCoverage($decoded);
|
|
||||||
$current = self::requireCoverage($reportPath);
|
|
||||||
|
|
||||||
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::primeUncoveredFiles($cached);
|
|
||||||
self::primeUncoveredFiles($current);
|
|
||||||
|
|
||||||
self::stripCurrentTestsFromCached($cached, $current);
|
|
||||||
|
|
||||||
$cached->merge($current);
|
|
||||||
|
|
||||||
$serialised = serialize($cached);
|
|
||||||
|
|
||||||
@file_put_contents(
|
|
||||||
$reportPath,
|
|
||||||
'<?php return unserialize('.var_export($serialised, true).");\n",
|
|
||||||
);
|
|
||||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function primeUncoveredFiles(CodeCoverage $coverage): void
|
|
||||||
{
|
|
||||||
$coverage->getData(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function compress(string $bytes): string
|
|
||||||
{
|
|
||||||
$compressed = @gzencode($bytes);
|
|
||||||
|
|
||||||
return $compressed === false ? $bytes : $compressed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function decompress(string $bytes): ?string
|
|
||||||
{
|
|
||||||
$decoded = @gzdecode($bytes);
|
|
||||||
|
|
||||||
return $decoded === false ? null : $decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
|
|
||||||
{
|
|
||||||
$currentIds = self::collectTestIds($current);
|
|
||||||
|
|
||||||
if ($currentIds === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cachedData = $cached->getData();
|
|
||||||
$lineCoverage = $cachedData->lineCoverage();
|
|
||||||
|
|
||||||
foreach ($lineCoverage as $file => $lines) {
|
|
||||||
foreach ($lines as $line => $ids) {
|
|
||||||
if ($ids === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($ids === []) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$filtered = array_values(array_diff($ids, $currentIds));
|
|
||||||
|
|
||||||
if ($filtered !== $ids) {
|
|
||||||
$lineCoverage[$file][$line] = $filtered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$cachedData->setLineCoverage($lineCoverage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private static function collectTestIds(CodeCoverage $coverage): array
|
|
||||||
{
|
|
||||||
$ids = [];
|
|
||||||
|
|
||||||
foreach ($coverage->getData()->lineCoverage() as $lines) {
|
|
||||||
foreach ($lines as $hits) {
|
|
||||||
if ($hits === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($hits as $id) {
|
|
||||||
$ids[$id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_keys($ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function state(): State
|
|
||||||
{
|
|
||||||
$state = Container::getInstance()->get(State::class);
|
|
||||||
assert($state instanceof State);
|
|
||||||
|
|
||||||
return $state;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
|
||||||
{
|
|
||||||
if (! is_file($reportPath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
/** @var mixed $value */
|
|
||||||
$value = require $reportPath;
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
|
|
||||||
if ($value instanceof CodeCoverage) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
|
|
||||||
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
|
|
||||||
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
|
|
||||||
// be rebuilt into one before it can be merged.
|
|
||||||
return self::coverageFromSerializedData($reportPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
|
|
||||||
{
|
|
||||||
if ($reportPath === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$serialized = new Unserializer()->unserialize($reportPath);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $serialized['codeCoverage'];
|
|
||||||
$basePath = $serialized['basePath'];
|
|
||||||
|
|
||||||
if ($basePath !== '') {
|
|
||||||
foreach ($data->coveredFiles() as $relativePath) {
|
|
||||||
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$coverage = self::emptyCoverage();
|
|
||||||
|
|
||||||
if (! $coverage instanceof CodeCoverage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$coverage->setData($data);
|
|
||||||
$coverage->setTests($serialized['testResults']);
|
|
||||||
|
|
||||||
return $coverage;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function emptyCoverage(): ?CodeCoverage
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$filter = new Filter;
|
|
||||||
|
|
||||||
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$value = @unserialize($bytes);
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value instanceof CodeCoverage ? $value : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Edges;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Recorder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class BladeEdges
|
|
||||||
{
|
|
||||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
|
||||||
|
|
||||||
private const string MARKER = 'pest.tia.blade-edges-armed';
|
|
||||||
|
|
||||||
public static function arm(Recorder $recorder): void
|
|
||||||
{
|
|
||||||
if (! $recorder->isActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$containerClass = self::CONTAINER_CLASS;
|
|
||||||
|
|
||||||
if (! class_exists($containerClass)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var object $app */
|
|
||||||
$app = $containerClass::getInstance();
|
|
||||||
|
|
||||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$app->instance(self::MARKER, true);
|
|
||||||
|
|
||||||
$factory = $app->make('view');
|
|
||||||
|
|
||||||
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$factory->composer('*', static function (object $view) use ($recorder): void {
|
|
||||||
if (! method_exists($view, 'getPath')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var mixed $path */
|
|
||||||
$path = $view->getPath();
|
|
||||||
|
|
||||||
if (is_string($path) && $path !== '') {
|
|
||||||
$recorder->linkSource($path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Edges;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Recorder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class InertiaEdges
|
|
||||||
{
|
|
||||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
|
||||||
|
|
||||||
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
|
||||||
|
|
||||||
private const string MARKER = 'pest.tia.inertia-edges-armed';
|
|
||||||
|
|
||||||
public static function arm(Recorder $recorder): void
|
|
||||||
{
|
|
||||||
if (! $recorder->isActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$containerClass = self::CONTAINER_CLASS;
|
|
||||||
|
|
||||||
if (! class_exists($containerClass)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var object $app */
|
|
||||||
$app = $containerClass::getInstance();
|
|
||||||
|
|
||||||
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$app->instance(self::MARKER, true);
|
|
||||||
|
|
||||||
/** @var object $events */
|
|
||||||
$events = $app->make('events');
|
|
||||||
|
|
||||||
if (! method_exists($events, 'listen')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
|
||||||
if (! property_exists($event, 'response') || ! is_object($event->response)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$component = self::extractComponent($event->response);
|
|
||||||
|
|
||||||
if ($component !== null) {
|
|
||||||
$recorder->linkInertiaComponent($component);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function extractComponent(object $response): ?string
|
|
||||||
{
|
|
||||||
$content = self::readContent($response);
|
|
||||||
|
|
||||||
if ($content === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::isInertiaJsonResponse($response)) {
|
|
||||||
return self::componentFromJson($content);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($content, 'type="application/json"')
|
|
||||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
|
||||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
|
||||||
|
|
||||||
if ($component !== null) {
|
|
||||||
return $component;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_contains($content, 'data-page=')
|
|
||||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
|
||||||
return self::componentFromJson(html_entity_decode($match[1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isInertiaJsonResponse(object $response): bool
|
|
||||||
{
|
|
||||||
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$headers = $response->headers;
|
|
||||||
|
|
||||||
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function componentFromJson(string $json): ?string
|
|
||||||
{
|
|
||||||
/** @var mixed $decoded */
|
|
||||||
$decoded = json_decode($json, true);
|
|
||||||
|
|
||||||
if (is_array($decoded)
|
|
||||||
&& isset($decoded['component'])
|
|
||||||
&& is_string($decoded['component'])
|
|
||||||
&& $decoded['component'] !== '') {
|
|
||||||
return $decoded['component'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function readContent(object $response): ?string
|
|
||||||
{
|
|
||||||
if (! method_exists($response, 'getContent')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var mixed $content */
|
|
||||||
$content = $response->getContent();
|
|
||||||
|
|
||||||
return is_string($content) ? $content : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Enums;
|
|
||||||
|
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
enum ReplayType
|
|
||||||
{
|
|
||||||
case None;
|
|
||||||
case Pass;
|
|
||||||
case Risky;
|
|
||||||
case Skipped;
|
|
||||||
case Incomplete;
|
|
||||||
case Failure;
|
|
||||||
|
|
||||||
public static function fromStatus(?TestStatus $status): self
|
|
||||||
{
|
|
||||||
if (! $status instanceof TestStatus) {
|
|
||||||
return self::None;
|
|
||||||
}
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$status->isSuccess() => self::Pass,
|
|
||||||
$status->isRisky() => self::Risky,
|
|
||||||
$status->isSkipped() => self::Skipped,
|
|
||||||
$status->isIncomplete() => self::Incomplete,
|
|
||||||
$status->isNotice(), $status->isDeprecation(), $status->isWarning() => self::Pass,
|
|
||||||
$status->isFailure(), $status->isError() => self::Failure,
|
|
||||||
default => self::None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\State;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class FileState implements State
|
|
||||||
{
|
|
||||||
private readonly string $rootDir;
|
|
||||||
|
|
||||||
private ?string $resolvedRoot = null;
|
|
||||||
|
|
||||||
public function __construct(string $rootDir)
|
|
||||||
{
|
|
||||||
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function read(string $key): ?string
|
|
||||||
{
|
|
||||||
$path = $this->pathFor($key);
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$bytes = @file_get_contents($path);
|
|
||||||
|
|
||||||
return $bytes === false ? null : $bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function write(string $key, string $content): bool
|
|
||||||
{
|
|
||||||
if (! $this->ensureRoot()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = $this->pathFor($key);
|
|
||||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
|
||||||
|
|
||||||
if (@file_put_contents($tmp, $content) === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! @rename($tmp, $path)) {
|
|
||||||
@unlink($tmp);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(string $key): bool
|
|
||||||
{
|
|
||||||
$path = $this->pathFor($key);
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return @unlink($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function exists(string $key): bool
|
|
||||||
{
|
|
||||||
return is_file($this->pathFor($key));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function keysWithPrefix(string $prefix): array
|
|
||||||
{
|
|
||||||
$root = $this->resolvedRoot();
|
|
||||||
|
|
||||||
if ($root === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
|
|
||||||
$matches = glob($pattern);
|
|
||||||
|
|
||||||
if ($matches === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$keys = [];
|
|
||||||
|
|
||||||
foreach ($matches as $path) {
|
|
||||||
if (str_ends_with($path, '.tmp')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$keys[] = basename($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function pathFor(string $key): string
|
|
||||||
{
|
|
||||||
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvedRoot(): ?string
|
|
||||||
{
|
|
||||||
if ($this->resolvedRoot !== null) {
|
|
||||||
return $this->resolvedRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved = @realpath($this->rootDir);
|
|
||||||
|
|
||||||
if ($resolved === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->resolvedRoot = $resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureRoot(): bool
|
|
||||||
{
|
|
||||||
if (is_dir($this->rootDir)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (@mkdir($this->rootDir, 0755, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_dir($this->rootDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
|
||||||
use Symfony\Component\Finder\Finder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class Fingerprint
|
|
||||||
{
|
|
||||||
private const int SCHEMA_VERSION = 18;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, class-string<Lockfile>>
|
|
||||||
*/
|
|
||||||
private const array LOCKFILES = [
|
|
||||||
Lockfiles\PackageLock::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* structural: array<string, int|string|null>,
|
|
||||||
* environmental: array<string, int|string|null>,
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public static function compute(string $projectRoot): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'structural' => [
|
|
||||||
'schema' => self::SCHEMA_VERSION,
|
|
||||||
'composer_lock' => self::composerLockHash($projectRoot),
|
|
||||||
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
|
|
||||||
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
|
|
||||||
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
|
||||||
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
|
||||||
'vite_config' => self::viteConfigHash($projectRoot),
|
|
||||||
// 'package_json' => self::packageJsonHash($projectRoot),
|
|
||||||
'package_lock' => self::packageLockHash($projectRoot),
|
|
||||||
'js_config' => self::jsConfigHash($projectRoot),
|
|
||||||
// 'composer_json' => self::composerJsonHash($projectRoot),
|
|
||||||
],
|
|
||||||
'environmental' => [
|
|
||||||
'php_minor' => PHP_MAJOR_VERSION,
|
|
||||||
|
|
||||||
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
|
||||||
// 'env_files' => self::envFilesHash($projectRoot),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $a
|
|
||||||
* @param array<string, mixed> $b
|
|
||||||
*/
|
|
||||||
public static function structuralMatches(array $a, array $b): bool
|
|
||||||
{
|
|
||||||
$aStructural = self::structuralOnly($a);
|
|
||||||
$bStructural = self::structuralOnly($b);
|
|
||||||
|
|
||||||
ksort($aStructural);
|
|
||||||
ksort($bStructural);
|
|
||||||
|
|
||||||
return $aStructural === $bStructural;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $stored
|
|
||||||
* @param array<string, mixed> $current
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function structuralDrift(array $stored, array $current): array
|
|
||||||
{
|
|
||||||
return self::detectDrift(
|
|
||||||
self::structuralOnly($stored),
|
|
||||||
self::structuralOnly($current),
|
|
||||||
'schema',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $stored
|
|
||||||
* @param array<string, mixed> $current
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function environmentalDrift(array $stored, array $current): array
|
|
||||||
{
|
|
||||||
return self::detectDrift(
|
|
||||||
self::environmentalOnly($stored),
|
|
||||||
self::environmentalOnly($current),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $a
|
|
||||||
* @param array<string, mixed> $b
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
|
|
||||||
{
|
|
||||||
$drifts = [];
|
|
||||||
|
|
||||||
foreach ($a as $key => $value) {
|
|
||||||
if ($key === $skipKey) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (($b[$key] ?? null) !== $value) {
|
|
||||||
$drifts[] = $key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($b as $key => $value) {
|
|
||||||
if ($key === $skipKey) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! array_key_exists($key, $a) && $value !== null) {
|
|
||||||
$drifts[] = $key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($drifts));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $fingerprint
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function structuralOnly(array $fingerprint): array
|
|
||||||
{
|
|
||||||
return self::bucket($fingerprint, 'structural');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $fingerprint
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function environmentalOnly(array $fingerprint): array
|
|
||||||
{
|
|
||||||
return self::bucket($fingerprint, 'environmental');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $fingerprint
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function bucket(array $fingerprint, string $key): array
|
|
||||||
{
|
|
||||||
$raw = $fingerprint[$key] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($raw)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalised = [];
|
|
||||||
|
|
||||||
foreach ($raw as $k => $v) {
|
|
||||||
if (is_string($k)) {
|
|
||||||
$normalised[$k] = $v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $normalised;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function viteConfigHash(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
|
|
||||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
|
||||||
|
|
||||||
if ($hash !== null) {
|
|
||||||
$parts[] = $name.':'.$hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function jsConfigHash(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
|
|
||||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = self::hashIfExists($projectRoot.'/'.$name);
|
|
||||||
|
|
||||||
if ($hash !== null) {
|
|
||||||
$parts[] = $name.':'.$hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function composerLockHash(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
return self::trackedHash($projectRoot, 'composer.lock');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function packageLockHash(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
|
||||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
|
||||||
|
|
||||||
if ($hash !== null) {
|
|
||||||
$parts[] = $name.':'.$hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function lockfileHash(string $path, string $name): ?string
|
|
||||||
{
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contents = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($contents === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::LOCKFILES as $class) {
|
|
||||||
$handler = new $class;
|
|
||||||
|
|
||||||
if (! $handler->applies($name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fingerprint = $handler->fingerprint($contents);
|
|
||||||
|
|
||||||
if ($fingerprint !== null) {
|
|
||||||
return $fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash('xxh128', $contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
|
||||||
{
|
|
||||||
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::hashIfExists($projectRoot.'/'.$relativePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true when the file exists and is not gitignored.
|
|
||||||
*
|
|
||||||
* Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
|
|
||||||
* regenerate per-machine with OS-specific optional deps, which would
|
|
||||||
* otherwise force a fingerprint mismatch on every fetched baseline.
|
|
||||||
*/
|
|
||||||
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
|
|
||||||
{
|
|
||||||
if (! is_file($projectRoot.'/'.$relativePath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static $cache = [];
|
|
||||||
|
|
||||||
$key = $projectRoot."\0".$relativePath;
|
|
||||||
|
|
||||||
if (isset($cache[$key])) {
|
|
||||||
return $cache[$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
|
|
||||||
return $cache[$key] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$finder = (new Finder)
|
|
||||||
->in($projectRoot)
|
|
||||||
->depth('== 0')
|
|
||||||
->name($relativePath)
|
|
||||||
->ignoreVCSIgnored(true);
|
|
||||||
|
|
||||||
return $cache[$key] = $finder->hasResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function contentHashOrNull(string $path): ?string
|
|
||||||
{
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = ContentHash::of($path);
|
|
||||||
|
|
||||||
return $hash === false ? null : $hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hashIfExists(string $path): ?string
|
|
||||||
{
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = @hash_file('xxh128', $path);
|
|
||||||
|
|
||||||
return $hash === false ? null : $hash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user