mirror of
https://github.com/pestphp/pest.git
synced 2026-07-25 19:10:02 +02:00
Compare commits
9 Commits
5.x
..
41f11c0ef3
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f11c0ef3 | |||
| e91634ff05 | |||
| df0f440f84 | |||
| 50601e6118 | |||
| 247d59abf6 | |||
| b24c375d72 | |||
| 30fff116fd | |||
| 192f289e7e | |||
| 4b8e303cd5 |
@@ -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 * * *'
|
||||||
@@ -24,16 +24,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 +44,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', '**/composer.lock') }}
|
||||||
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
|
||||||
|
|||||||
@@ -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 * * *'
|
||||||
@@ -24,18 +24,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,7 +51,7 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@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', '**/composer.lock') }}
|
||||||
@@ -63,8 +66,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,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/pest-v5-is-here)**.
|
> 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,12 +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)**
|
||||||
- **[Redberry](https://redberry.international/laravel-development/)**
|
|
||||||
|
### 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
-17
@@ -17,20 +17,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.3.0",
|
||||||
"brianium/paratest": "^7.23.0",
|
"brianium/paratest": "^7.20.0",
|
||||||
"nunomaduro/collision": "^8.9.5",
|
"nunomaduro/collision": "^8.9.3",
|
||||||
"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.0",
|
"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.4",
|
"phpunit/phpunit": "^12.5.20",
|
||||||
"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.4",
|
"phpunit/phpunit": ">12.5.20",
|
||||||
"sebastian/exporter": "<7.0.0",
|
"sebastian/exporter": "<7.0.0",
|
||||||
"webmozart/assert": "<1.11.0"
|
"webmozart/assert": "<1.11.0"
|
||||||
},
|
},
|
||||||
@@ -58,13 +58,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/pao": "^1.1.2",
|
"mrpunyapal/peststan": "^0.2.5",
|
||||||
"pestphp/pest-dev-tools": "^5.0.0",
|
"pestphp/pest-dev-tools": "^4.1.0",
|
||||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||||
"pestphp/pest-plugin-rector": "^5.0.0",
|
"psy/psysh": "^0.12.22"
|
||||||
"pestphp/pest-plugin-type-coverage": "^5.0.0",
|
|
||||||
"psy/psysh": "^0.12.24"
|
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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
|
||||||
|
|||||||
-38
@@ -2,60 +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',
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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',
|
||||||
|
|||||||
@@ -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,17 +25,9 @@ final readonly class BootSubscribers implements Bootstrapper
|
|||||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||||
Subscribers\EnsureTeamCityEnabled::class,
|
Subscribers\EnsureTeamCityEnabled::class,
|
||||||
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
||||||
Subscribers\EnsureTiaStarts::class,
|
Subscribers\EnsureTiaCoverageIsFlushed::class,
|
||||||
Subscribers\EnsureTiaEnds::class,
|
|
||||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
|
||||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
|
||||||
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+39
-75
@@ -5,26 +5,21 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Concerns;
|
namespace Pest\Concerns;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use Pest\Contracts\Plugins\BeforeEachable;
|
||||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugin\Loader;
|
||||||
use Pest\Plugins\Tia\Collectors;
|
use Pest\Plugins\Tia\CachedTestResult;
|
||||||
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;
|
||||||
@@ -84,15 +79,10 @@ trait Testable
|
|||||||
public bool $__ran = false;
|
public bool $__ran = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The active replay mode for this test, set in `setUp()` and checked
|
* Set when a `BeforeEachable` plugin returns a cached success result.
|
||||||
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
|
||||||
*/
|
*/
|
||||||
private ReplayType $__replay = ReplayType::None;
|
private bool $__cachedPass = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
||||||
@@ -246,6 +236,31 @@ trait Testable
|
|||||||
{
|
{
|
||||||
TestSuite::getInstance()->test = $this;
|
TestSuite::getInstance()->test = $this;
|
||||||
|
|
||||||
|
$this->__cachedPass = false;
|
||||||
|
|
||||||
|
/** @var BeforeEachable $plugin */
|
||||||
|
foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) {
|
||||||
|
$cached = $plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name());
|
||||||
|
|
||||||
|
if ($cached instanceof CachedTestResult) {
|
||||||
|
if ($cached->isSuccess()) {
|
||||||
|
$this->__cachedPass = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-success: throw appropriate exception. PHPUnit catches
|
||||||
|
// it in runBare() and marks the test with the correct status.
|
||||||
|
// This makes skips, failures, incompletes, todos appear in
|
||||||
|
// output exactly as if the test ran.
|
||||||
|
match ($cached->status) {
|
||||||
|
1 => $this->markTestSkipped($cached->message), // skip / todo
|
||||||
|
2 => $this->markTestIncomplete($cached->message), // incomplete
|
||||||
|
default => throw new \PHPUnit\Framework\AssertionFailedError($cached->message ?: 'Cached failure'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||||
|
|
||||||
$description = $method->description;
|
$description = $method->description;
|
||||||
@@ -278,35 +293,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::class.'::'.$this->name());
|
|
||||||
$replay = ReplayType::fromStatus($status);
|
|
||||||
|
|
||||||
if ($replay !== ReplayType::None) {
|
|
||||||
assert($status !== null);
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -316,13 +304,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::class.'::'.$this->name());
|
|
||||||
$this->__ran = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize test case properties from TestSuite.
|
* Initialize test case properties from TestSuite.
|
||||||
*/
|
*/
|
||||||
@@ -355,7 +336,7 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
protected function tearDown(...$arguments): void
|
protected function tearDown(...$arguments): void
|
||||||
{
|
{
|
||||||
if ($this->__replay !== ReplayType::None) {
|
if ($this->__cachedPass) {
|
||||||
TestSuite::getInstance()->test = null;
|
TestSuite::getInstance()->test = null;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -386,12 +367,8 @@ 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->__cachedPass) {
|
||||||
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
$this->addToAssertionCount(1);
|
||||||
$this->expectNotToPerformAssertions();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->addToAssertionCount($this->__replayAssertions);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -433,15 +410,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();
|
||||||
@@ -457,9 +429,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');
|
||||||
|
|
||||||
@@ -467,19 +437,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
-11
@@ -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,14 +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.
|
* Gets the TIA (Test Impact Analysis) configuration.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Contracts\Plugins;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\CachedTestResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugins implementing this interface are consulted before each test's
|
||||||
|
* `setUp()`. The return value controls what happens:
|
||||||
|
*
|
||||||
|
* - `null` → test proceeds normally.
|
||||||
|
* - `CachedTestResult` → test replays the cached status. For non-success
|
||||||
|
* statuses the appropriate exception is thrown
|
||||||
|
* from `setUp` (PHPUnit handles it natively). For
|
||||||
|
* success, a synthetic assertion is registered and
|
||||||
|
* the body + tearDown are skipped via a flag.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface BeforeEachable
|
||||||
|
{
|
||||||
|
public function beforeEach(string $filename, string $testId): ?CachedTestResult;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,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')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,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);
|
||||||
|
|
||||||
@@ -150,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
|
||||||
@@ -161,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;
|
||||||
|
|
||||||
@@ -170,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
|
||||||
}
|
}
|
||||||
@@ -201,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;
|
||||||
|
|||||||
+14
-34
@@ -13,6 +13,7 @@ use Pest\Plugins\Actions\CallsBoot;
|
|||||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||||
use Pest\Plugins\Actions\CallsHandleOriginalArguments;
|
use Pest\Plugins\Actions\CallsHandleOriginalArguments;
|
||||||
use Pest\Plugins\Actions\CallsTerminable;
|
use Pest\Plugins\Actions\CallsTerminable;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use Pest\Support\Reflection;
|
use Pest\Support\Reflection;
|
||||||
use Pest\Support\View;
|
use Pest\Support\View;
|
||||||
@@ -27,13 +28,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 +37,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 +44,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.
|
||||||
@@ -78,7 +65,10 @@ final class Kernel
|
|||||||
->add(TestSuite::class, $testSuite)
|
->add(TestSuite::class, $testSuite)
|
||||||
->add(InputInterface::class, $input)
|
->add(InputInterface::class, $input)
|
||||||
->add(OutputInterface::class, $output)
|
->add(OutputInterface::class, $output)
|
||||||
->add(Container::class, $container);
|
->add(Container::class, $container)
|
||||||
|
->add(Tia\Recorder::class, new Tia\Recorder)
|
||||||
|
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
|
||||||
|
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
|
||||||
|
|
||||||
$kernel = new self(
|
$kernel = new self(
|
||||||
new Application,
|
new Application,
|
||||||
@@ -126,13 +116,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 +126,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 +143,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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-134
@@ -936,7 +936,7 @@ final class Expectation
|
|||||||
|
|
||||||
if ($exception instanceof Closure) {
|
if ($exception instanceof Closure) {
|
||||||
$callback = $exception;
|
$callback = $exception;
|
||||||
$parameters = new ReflectionFunction($exception)->getParameters();
|
$parameters = (new ReflectionFunction($exception))->getParameters();
|
||||||
|
|
||||||
if (count($parameters) !== 1) {
|
if (count($parameters) !== 1) {
|
||||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||||
@@ -954,7 +954,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 +1141,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 +1154,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 +1186,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.0-beta.1';
|
return '4.6.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testDirectory(string $file = ''): string
|
function testDirectory(string $file = ''): string
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasArgument('--parallel', $arguments) && ! $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);
|
||||||
|
|||||||
@@ -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,11 +7,11 @@ namespace Pest\Plugins\Parallel\Paratest;
|
|||||||
use const DIRECTORY_SEPARATOR;
|
use const DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||||
|
use ParaTest\Coverage\CoverageMerger;
|
||||||
use ParaTest\JUnit\LogMerger;
|
use ParaTest\JUnit\LogMerger;
|
||||||
use ParaTest\JUnit\Writer;
|
use ParaTest\JUnit\Writer;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
use ParaTest\RunnerInterface;
|
use ParaTest\RunnerInterface;
|
||||||
use ParaTest\WrapperRunner\MissingResultsException;
|
|
||||||
use ParaTest\WrapperRunner\SuiteLoader;
|
use ParaTest\WrapperRunner\SuiteLoader;
|
||||||
use ParaTest\WrapperRunner\WrapperWorker;
|
use ParaTest\WrapperRunner\WrapperWorker;
|
||||||
use Pest\Result;
|
use Pest\Result;
|
||||||
@@ -25,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;
|
||||||
@@ -43,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;
|
||||||
@@ -57,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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,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 = [];
|
||||||
|
|
||||||
@@ -166,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();
|
||||||
@@ -232,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) {
|
||||||
@@ -247,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,
|
||||||
@@ -306,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;
|
||||||
}
|
}
|
||||||
@@ -347,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;
|
||||||
@@ -395,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()),
|
||||||
@@ -444,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(),
|
||||||
@@ -488,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);
|
||||||
@@ -507,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,
|
||||||
@@ -536,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);
|
||||||
|
|||||||
+12
-105
@@ -27,13 +27,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string SHARD_OPTION = 'shard';
|
private const string SHARD_OPTION = 'shard';
|
||||||
|
|
||||||
/**
|
|
||||||
* The maximum length allowed for the filter argument.
|
|
||||||
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
|
|
||||||
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
|
|
||||||
*/
|
|
||||||
private const int MAX_FILTER_LENGTH = 32768;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shard index and total number of shards.
|
* The shard index and total number of shards.
|
||||||
*
|
*
|
||||||
@@ -139,8 +132,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
self::$timeBalanced = true;
|
self::$timeBalanced = true;
|
||||||
self::$shardsOutdated = $newTests !== [];
|
self::$shardsOutdated = $newTests !== [];
|
||||||
} else {
|
} else {
|
||||||
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||||
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$shard = [
|
self::$shard = [
|
||||||
@@ -154,11 +146,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$filter = $this->buildFilterArgument($testsToRun);
|
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||||
|
|
||||||
$this->ensureFilterLengthIsSafe($filter);
|
|
||||||
|
|
||||||
return [...$arguments, '--filter', $filter];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,14 +187,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',
|
||||||
|
]))->setTimeout(120)->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 +204,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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+403
-1428
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a previous test run's outcome. Stored in the TIA
|
||||||
|
* graph and returned by `BeforeEachable::beforeEach` so `Testable` can
|
||||||
|
* faithfully replay the exact status — pass, fail, skip, todo, incomplete,
|
||||||
|
* risky, etc. — without executing the test body.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class CachedTestResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* PHPUnit TestStatus int constants:
|
||||||
|
* 0 = success, 1 = skipped, 2 = incomplete,
|
||||||
|
* 3 = notice, 4 = deprecation, 5 = risky,
|
||||||
|
* 6 = warning, 7 = failure, 8 = error.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $status,
|
||||||
|
public string $message = '',
|
||||||
|
public float $time = 0.0,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-119
@@ -4,10 +4,22 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Exceptions\MissingDependency;
|
|
||||||
use Symfony\Component\Process\Process;
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Detects files that changed between the last recorded TIA run and the
|
||||||
|
* current working tree.
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. If we have a `recordedAtSha`, `git diff <sha>..HEAD` captures committed
|
||||||
|
* changes on top of the recording point.
|
||||||
|
* 2. `git status --short` captures unstaged + staged + untracked changes on
|
||||||
|
* top of that.
|
||||||
|
*
|
||||||
|
* We return relative paths to the project root. Deletions are included so the
|
||||||
|
* caller can decide whether to invalidate: a deleted source file may still
|
||||||
|
* appear in the graph and should mark its dependents as affected.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class ChangedFiles
|
final readonly class ChangedFiles
|
||||||
@@ -15,7 +27,18 @@ final readonly class ChangedFiles
|
|||||||
public function __construct(private string $projectRoot) {}
|
public function __construct(private string $projectRoot) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $files project-relative paths.
|
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||||
|
* the recorded SHA is no longer reachable
|
||||||
|
* from HEAD (rebase / force-push) — in
|
||||||
|
* that case the graph should be rebuilt.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Removes files whose current content hash matches the snapshot from the
|
||||||
|
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
|
||||||
|
* that git still reports as modified but whose content is bit-identical
|
||||||
|
* to the previous TIA invocation.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $files project-relative paths.
|
||||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@@ -25,19 +48,27 @@ final readonly class ChangedFiles
|
|||||||
return $files;
|
return $files;
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates = array_fill_keys($files, true);
|
|
||||||
|
|
||||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
|
||||||
$candidates[$snapshotted] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining = [];
|
$remaining = [];
|
||||||
|
|
||||||
foreach (array_keys($candidates) as $file) {
|
foreach ($files as $file) {
|
||||||
$snapshot = $lastRunTree[$file] ?? null;
|
if (! isset($lastRunTree[$file])) {
|
||||||
$current = $this->currentHash($file);
|
$remaining[] = $file;
|
||||||
|
|
||||||
if ($snapshot === null || $current === null || $current !== $snapshot) {
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
// File deleted since last run — definitely changed.
|
||||||
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = @hash_file('xxh128', $absolute);
|
||||||
|
|
||||||
|
if ($hash === false || $hash !== $lastRunTree[$file]) {
|
||||||
$remaining[] = $file;
|
$remaining[] = $file;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,22 +76,13 @@ final readonly class ChangedFiles
|
|||||||
return $remaining;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Computes content hashes for the given project-relative files. Used to
|
||||||
|
* snapshot the working tree after a successful run so the next run can
|
||||||
|
* detect which files are actually different.
|
||||||
|
*
|
||||||
* @param array<int, string> $files
|
* @param array<int, string> $files
|
||||||
* @return array<string, string> path → xxh128 content hash
|
* @return array<string, string> path → xxh128 content hash
|
||||||
*/
|
*/
|
||||||
public function snapshotTree(array $files): array
|
public function snapshotTree(array $files): array
|
||||||
{
|
{
|
||||||
@@ -70,12 +92,10 @@ final readonly class ChangedFiles
|
|||||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||||
|
|
||||||
if (! is_file($absolute)) {
|
if (! is_file($absolute)) {
|
||||||
$out[$file] = '';
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hash = ContentHash::of($absolute);
|
$hash = @hash_file('xxh128', $absolute);
|
||||||
|
|
||||||
if ($hash !== false) {
|
if ($hash !== false) {
|
||||||
$out[$file] = $hash;
|
$out[$file] = $hash;
|
||||||
@@ -87,9 +107,15 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>|null `null` when git is unavailable, or when
|
* @return array<int, string>|null `null` when git is unavailable, or when
|
||||||
|
* the recorded SHA is no longer reachable
|
||||||
|
* from HEAD (rebase / force-push).
|
||||||
*/
|
*/
|
||||||
public function since(?string $sha): ?array
|
public function since(?string $sha): ?array
|
||||||
{
|
{
|
||||||
|
if (! $this->gitAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|
||||||
if ($sha !== null && $sha !== '') {
|
if ($sha !== null && $sha !== '') {
|
||||||
@@ -102,123 +128,69 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
$files = array_merge($files, $this->workingTreeChanges());
|
$files = array_merge($files, $this->workingTreeChanges());
|
||||||
|
|
||||||
|
// Normalise + dedupe, filtering out paths that can never belong to the
|
||||||
|
// graph: vendor (caught by the fingerprint instead), cache dirs, and
|
||||||
|
// anything starting with a dot we don't care about.
|
||||||
$unique = [];
|
$unique = [];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($files as $file) {
|
||||||
if ($file === '') {
|
if ($file === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($this->shouldIgnore($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$unique[$file] = true;
|
$unique[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$candidates = array_keys($this->filterIgnored($unique));
|
return array_keys($unique);
|
||||||
|
|
||||||
if ($sha !== null && $sha !== '') {
|
|
||||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function shouldIgnore(string $path): bool
|
||||||
* @param array<int, string> $files
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function filterBehaviourallyUnchanged(array $files, string $sha): array
|
|
||||||
{
|
{
|
||||||
$remaining = [];
|
static $prefixes = [
|
||||||
|
'.pest/',
|
||||||
|
'.phpunit.cache/',
|
||||||
|
'.phpunit.result.cache',
|
||||||
|
'vendor/',
|
||||||
|
'node_modules/',
|
||||||
|
];
|
||||||
|
|
||||||
foreach ($files as $file) {
|
foreach ($prefixes as $prefix) {
|
||||||
$currentHash = $this->currentHash($file);
|
if (str_starts_with($path, (string) $prefix)) {
|
||||||
|
return true;
|
||||||
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function contentAtSha(string $sha, string $path): ?string
|
public function currentBranch(): ?string
|
||||||
{
|
{
|
||||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
if (! $this->gitAvailable()) {
|
||||||
$process->setTimeout(5.0);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $process->getOutput();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, true> $candidates
|
|
||||||
* @return array<string, true>
|
|
||||||
*/
|
|
||||||
private function filterIgnored(array $candidates): array
|
|
||||||
{
|
|
||||||
if ($candidates === []) {
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = new Process(
|
|
||||||
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
|
|
||||||
$this->projectRoot,
|
|
||||||
);
|
|
||||||
$process->setTimeout(5.0);
|
|
||||||
$process->setInput(implode("\x00", array_keys($candidates)));
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
$exitCode = $process->getExitCode();
|
|
||||||
|
|
||||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = $process->getOutput();
|
|
||||||
|
|
||||||
if ($output === '') {
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
|
|
||||||
if ($ignored !== '') {
|
|
||||||
unset($candidates[$ignored]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentBranch(): ?string
|
|
||||||
{
|
|
||||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
|
||||||
}
|
|
||||||
|
|
||||||
$branch = trim($process->getOutput());
|
$branch = trim($process->getOutput());
|
||||||
|
|
||||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function gitAvailable(): bool
|
||||||
|
{
|
||||||
|
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
return $process->isSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
private function shaIsReachable(string $sha): bool
|
private function shaIsReachable(string $sha): bool
|
||||||
{
|
{
|
||||||
$process = new Process(
|
$process = new Process(
|
||||||
@@ -227,6 +199,9 @@ final readonly class ChangedFiles
|
|||||||
);
|
);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
|
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
|
||||||
|
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
|
||||||
|
// "unreachable" and force a rebuild.
|
||||||
return $process->getExitCode() === 0;
|
return $process->getExitCode() === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +217,7 @@ final readonly class ChangedFiles
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->splitLines($process->getOutput());
|
return $this->splitLines($process->getOutput());
|
||||||
@@ -253,6 +228,14 @@ final readonly class ChangedFiles
|
|||||||
*/
|
*/
|
||||||
private function workingTreeChanges(): array
|
private function workingTreeChanges(): array
|
||||||
{
|
{
|
||||||
|
// `-z` produces NUL-terminated records with no path quoting, so paths
|
||||||
|
// that contain spaces, tabs, unicode or other special characters
|
||||||
|
// are passed through verbatim. Without `-z`, git wraps such paths in
|
||||||
|
// quotes with backslash escapes, which would corrupt our lookup keys.
|
||||||
|
//
|
||||||
|
// Record format: `XY <SP> <path> <NUL>` for most entries, and
|
||||||
|
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
|
||||||
|
// fields).
|
||||||
$process = new Process(
|
$process = new Process(
|
||||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||||
$this->projectRoot,
|
$this->projectRoot,
|
||||||
@@ -260,7 +243,7 @@ final readonly class ChangedFiles
|
|||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$output = $process->getOutput();
|
$output = $process->getOutput();
|
||||||
@@ -283,6 +266,8 @@ final readonly class ChangedFiles
|
|||||||
$status = substr($record, 0, 2);
|
$status = substr($record, 0, 2);
|
||||||
$path = substr($record, 3);
|
$path = substr($record, 3);
|
||||||
|
|
||||||
|
// Renames/copies emit two records: the new path first, then the
|
||||||
|
// original. Consume both.
|
||||||
if ($status[0] === 'R' || $status[0] === 'C') {
|
if ($status[0] === 'R' || $status[0] === 'C') {
|
||||||
$files[] = $path;
|
$files[] = $path;
|
||||||
|
|
||||||
@@ -302,11 +287,15 @@ final readonly class ChangedFiles
|
|||||||
|
|
||||||
public function currentSha(): ?string
|
public function currentSha(): ?string
|
||||||
{
|
{
|
||||||
|
if (! $this->gitAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
if (! $process->isSuccessful()) {
|
||||||
throw new MissingDependency('Tia mode', 'git');
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sha = trim($process->getOutput());
|
$sha = trim($process->getOutput());
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,60 +7,27 @@ namespace Pest\Plugins\Tia;
|
|||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* User-facing TIA configuration, returned by `pest()->tia()`.
|
||||||
|
*
|
||||||
|
* Usage in `tests/Pest.php`:
|
||||||
|
*
|
||||||
|
* pest()->tia()->watch([
|
||||||
|
* 'resources/js/**\/*.tsx' => 'tests/Browser',
|
||||||
|
* 'public/build/**\/*' => 'tests/Browser',
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* Patterns are merged with the built-in defaults (config, routes, views,
|
||||||
|
* frontend assets, migrations). Duplicate glob keys overwrite the default
|
||||||
|
* mapping so users can redirect a pattern to a narrower directory.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Configuration
|
final class Configuration
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return $this
|
* Adds watch-pattern → test-directory mappings that supplement (or
|
||||||
*/
|
* override) the built-in defaults.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, string> $patterns glob → project-relative test dir
|
* @param array<string, string> $patterns glob → project-relative test dir
|
||||||
* @return $this
|
* @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,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,35 +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,
|
|
||||||
default => self::Failure,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+56
-286
@@ -4,51 +4,40 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
|
||||||
use Symfony\Component\Finder\Finder;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Captures environmental inputs that, when changed, make the TIA graph stale.
|
||||||
|
*
|
||||||
|
* Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change
|
||||||
|
* what a test actually exercises, so the graph must be rebuilt in those cases.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Fingerprint
|
final readonly class Fingerprint
|
||||||
{
|
{
|
||||||
private const int SCHEMA_VERSION = 18;
|
// Bump this whenever the set of inputs or the hash algorithm changes, so
|
||||||
|
// older graphs are invalidated automatically.
|
||||||
|
private const int SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<int, class-string<Lockfile>>
|
* @return array<string, int|string|null>
|
||||||
*/
|
|
||||||
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
|
public static function compute(string $projectRoot): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'structural' => [
|
'schema' => self::SCHEMA_VERSION,
|
||||||
'schema' => self::SCHEMA_VERSION,
|
'php' => PHP_VERSION,
|
||||||
'composer_lock' => self::composerLockHash($projectRoot),
|
'pest' => self::readPestVersion($projectRoot),
|
||||||
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
|
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
||||||
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
|
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||||
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||||
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
||||||
'vite_config' => self::viteConfigHash($projectRoot),
|
// Pest's generated classes bake the code-generation logic in — if
|
||||||
// 'package_json' => self::packageJsonHash($projectRoot),
|
// TestCaseFactory changes (new attribute, different method
|
||||||
'package_lock' => self::packageLockHash($projectRoot),
|
// signature, etc.) every previously-recorded edge is stale.
|
||||||
'js_config' => self::jsConfigHash($projectRoot),
|
// Hashing the factory sources makes path-repo / dev-main installs
|
||||||
// 'composer_json' => self::composerJsonHash($projectRoot),
|
// automatically rebuild their graphs when Pest itself is edited.
|
||||||
],
|
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||||
'environmental' => [
|
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||||
'php_minor' => PHP_MAJOR_VERSION,
|
|
||||||
|
|
||||||
// 'extensions' => self::extensionsFingerprint($projectRoot),
|
|
||||||
// 'env_files' => self::envFilesHash($projectRoot),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,260 +45,12 @@ final readonly class Fingerprint
|
|||||||
* @param array<string, mixed> $a
|
* @param array<string, mixed> $a
|
||||||
* @param array<string, mixed> $b
|
* @param array<string, mixed> $b
|
||||||
*/
|
*/
|
||||||
public static function structuralMatches(array $a, array $b): bool
|
public static function matches(array $a, array $b): bool
|
||||||
{
|
{
|
||||||
$aStructural = self::structuralOnly($a);
|
ksort($a);
|
||||||
$bStructural = self::structuralOnly($b);
|
ksort($b);
|
||||||
|
|
||||||
ksort($aStructural);
|
return $a === $b;
|
||||||
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
|
private static function hashIfExists(string $path): ?string
|
||||||
@@ -322,4 +63,33 @@ final readonly class Fingerprint
|
|||||||
|
|
||||||
return $hash === false ? null : $hash;
|
return $hash === false ? null : $hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function readPestVersion(string $projectRoot): string
|
||||||
|
{
|
||||||
|
$installed = $projectRoot.'/vendor/composer/installed.json';
|
||||||
|
|
||||||
|
if (! is_file($installed)) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($installed);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['packages'] as $package) {
|
||||||
|
if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') {
|
||||||
|
return (string) ($package['version'] ?? 'unknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-1237
File diff suppressed because it is too large
Load Diff
@@ -1,393 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Symfony\Component\Process\ExecutableFinder;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class JsModuleGraph
|
|
||||||
{
|
|
||||||
private const int NODE_TIMEOUT_SECONDS = 180;
|
|
||||||
|
|
||||||
private const string CACHE_FILE = 'js-module-graph.cache.json';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
public const array VITE_CONFIG_NAMES = [
|
|
||||||
'vite.config.ts',
|
|
||||||
'vite.config.js',
|
|
||||||
'vite.config.mjs',
|
|
||||||
'vite.config.cjs',
|
|
||||||
'vite.config.mts',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Candidate page directories, in priority order. Must stay in sync with
|
|
||||||
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const array PAGE_DIR_CANDIDATES = [
|
|
||||||
'resources/js/Pages',
|
|
||||||
'resources/js/pages',
|
|
||||||
'assets/js/Pages',
|
|
||||||
'assets/js/pages',
|
|
||||||
'assets/Pages',
|
|
||||||
'assets/pages',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const array PAGE_EXTENSIONS = [
|
|
||||||
'vue', 'svelte',
|
|
||||||
'tsx', 'jsx',
|
|
||||||
'ts', 'js',
|
|
||||||
'mts', 'cts', 'mjs', 'cjs',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>
|
|
||||||
*/
|
|
||||||
public static function build(string $projectRoot): array
|
|
||||||
{
|
|
||||||
$result = self::resolve($projectRoot);
|
|
||||||
|
|
||||||
return $result ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>|null
|
|
||||||
*/
|
|
||||||
public static function buildStrict(string $projectRoot): ?array
|
|
||||||
{
|
|
||||||
return self::resolve($projectRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function isApplicable(string $projectRoot): bool
|
|
||||||
{
|
|
||||||
if (! self::hasViteConfig($projectRoot)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::firstExistingPagesDir($projectRoot) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function firstExistingPagesDir(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
|
|
||||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
|
|
||||||
|
|
||||||
if (! is_dir($abs)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::dirHasPageFile($abs)) {
|
|
||||||
return $abs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function dirHasPageFile(string $dir): bool
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
|
||||||
);
|
|
||||||
} catch (\UnexpectedValueException) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var \SplFileInfo $file */
|
|
||||||
foreach ($iterator as $file) {
|
|
||||||
if (! $file->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>|null
|
|
||||||
*/
|
|
||||||
private static function resolve(string $projectRoot): ?array
|
|
||||||
{
|
|
||||||
$fingerprint = self::fingerprint($projectRoot);
|
|
||||||
|
|
||||||
if ($fingerprint !== null) {
|
|
||||||
$cached = self::readCache($projectRoot, $fingerprint);
|
|
||||||
|
|
||||||
if ($cached !== null) {
|
|
||||||
return $cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = self::buildNodeProcess($projectRoot);
|
|
||||||
|
|
||||||
if (! $process instanceof Process) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (! $process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = self::parseNodeOutput($process->getOutput());
|
|
||||||
|
|
||||||
if ($result !== null && $fingerprint !== null) {
|
|
||||||
self::writeCache($projectRoot, $fingerprint, $result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function buildNodeProcess(string $projectRoot): ?Process
|
|
||||||
{
|
|
||||||
if (! self::hasViteConfig($projectRoot)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
|
||||||
|
|
||||||
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nodeBinary = (new ExecutableFinder)->find('node');
|
|
||||||
|
|
||||||
if ($nodeBinary === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
|
|
||||||
|
|
||||||
if (! is_file($helperPath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
|
|
||||||
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
|
||||||
|
|
||||||
return $process;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>|null
|
|
||||||
*/
|
|
||||||
private static function parseNodeOutput(string $output): ?array
|
|
||||||
{
|
|
||||||
/** @var mixed $decoded */
|
|
||||||
$decoded = json_decode($output, true);
|
|
||||||
|
|
||||||
if (! is_array($decoded)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($decoded as $path => $components) {
|
|
||||||
if (! is_string($path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($components)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$names = [];
|
|
||||||
|
|
||||||
foreach ($components as $component) {
|
|
||||||
if (is_string($component) && $component !== '') {
|
|
||||||
$names[] = $component;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($names !== []) {
|
|
||||||
sort($names);
|
|
||||||
$out[$path] = $names;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($out);
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function fingerprint(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
|
||||||
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stat = @stat($path);
|
|
||||||
$bytes = @file_get_contents($path);
|
|
||||||
|
|
||||||
$parts[] = 'config:'.$name
|
|
||||||
.':'.($stat === false ? '0' : (string) $stat['mtime'])
|
|
||||||
.':'.($stat === false ? '0' : (string) $stat['size'])
|
|
||||||
.':'.($bytes === false ? '' : hash('sha256', $bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parts === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$override = getenv('TIA_VITE_PAGES_DIR');
|
|
||||||
|
|
||||||
if (is_string($override) && $override !== '') {
|
|
||||||
$parts[] = 'pagesDirOverride:'.$override;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pagesDir = self::firstExistingPagesDir($projectRoot);
|
|
||||||
|
|
||||||
if ($pagesDir !== null) {
|
|
||||||
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
|
|
||||||
|
|
||||||
if ($jsRoot !== null && is_dir($jsRoot)) {
|
|
||||||
$entries = [];
|
|
||||||
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
|
||||||
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
|
|
||||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
|
||||||
);
|
|
||||||
|
|
||||||
/** @var \SplFileInfo $file */
|
|
||||||
foreach ($iterator as $file) {
|
|
||||||
if (! $file->isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entries[] = $file->getPathname()
|
|
||||||
.':'.$file->getSize()
|
|
||||||
.':'.$file->getMTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($entries);
|
|
||||||
|
|
||||||
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
|
|
||||||
}
|
|
||||||
|
|
||||||
return hash('sha256', implode('|', $parts));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, list<string>>|null
|
|
||||||
*/
|
|
||||||
private static function readCache(string $projectRoot, string $fingerprint): ?array
|
|
||||||
{
|
|
||||||
$path = self::cachePath($projectRoot);
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var mixed $decoded */
|
|
||||||
$decoded = json_decode($raw, true);
|
|
||||||
|
|
||||||
if (! is_array($decoded)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$graph = $decoded['graph'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($graph)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($graph as $key => $value) {
|
|
||||||
if (! is_string($key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$names = [];
|
|
||||||
|
|
||||||
foreach ($value as $name) {
|
|
||||||
if (is_string($name) && $name !== '') {
|
|
||||||
$names[] = $name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[$key] = $names;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, list<string>> $graph
|
|
||||||
*/
|
|
||||||
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
|
|
||||||
{
|
|
||||||
$path = self::cachePath($projectRoot);
|
|
||||||
$dir = dirname($path);
|
|
||||||
|
|
||||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = json_encode([
|
|
||||||
'fingerprint' => $fingerprint,
|
|
||||||
'graph' => $graph,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($payload === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
|
|
||||||
|
|
||||||
if (@file_put_contents($tmp, $payload) === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! @rename($tmp, $path)) {
|
|
||||||
@unlink($tmp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function cachePath(string $projectRoot): string
|
|
||||||
{
|
|
||||||
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function hasViteConfig(string $projectRoot): bool
|
|
||||||
{
|
|
||||||
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia\Lockfiles;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class PackageLock implements Lockfile
|
|
||||||
{
|
|
||||||
public function applies(string $filename): bool
|
|
||||||
{
|
|
||||||
return $filename === 'package-lock.json';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function fingerprint(string $contents): ?string
|
|
||||||
{
|
|
||||||
$data = json_decode($contents, true);
|
|
||||||
|
|
||||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$packages = $data['packages'];
|
|
||||||
|
|
||||||
$platformPaths = [];
|
|
||||||
|
|
||||||
foreach ($packages as $path => $meta) {
|
|
||||||
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
|
|
||||||
$platformPaths[] = $path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$entries = [];
|
|
||||||
|
|
||||||
foreach ($packages as $path => $meta) {
|
|
||||||
if (! is_string($path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($meta)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($this->isPlatformSpecific($meta)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$version = $meta['version'] ?? null;
|
|
||||||
|
|
||||||
$entries[$path] = is_string($version) ? $version : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($entries);
|
|
||||||
|
|
||||||
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
|
|
||||||
|
|
||||||
return $encoded === false ? null : hash('xxh128', $encoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $meta
|
|
||||||
*/
|
|
||||||
private function isPlatformSpecific(array $meta): bool
|
|
||||||
{
|
|
||||||
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $platformPaths
|
|
||||||
*/
|
|
||||||
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
|
|
||||||
{
|
|
||||||
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+81
-243
@@ -4,58 +4,47 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\TestSuite;
|
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Captures per-test file coverage using the PCOV driver.
|
||||||
|
*
|
||||||
|
* Acts as a singleton because PCOV has a single global collection state and
|
||||||
|
* the recorder is wired into PHPUnit through two distinct subscribers
|
||||||
|
* (`Prepared` / `Finished`) that must share context.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class Recorder
|
final class Recorder
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Test file currently being recorded, or `null` when idle.
|
||||||
|
*/
|
||||||
private ?string $currentTestFile = null;
|
private ?string $currentTestFile = null;
|
||||||
|
|
||||||
/** @var array<string, array<string, true>> */
|
/**
|
||||||
|
* Aggregated map: absolute test file → set<absolute source file>.
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, true>>
|
||||||
|
*/
|
||||||
private array $perTestFiles = [];
|
private array $perTestFiles = [];
|
||||||
|
|
||||||
/** @var array<string, array<string, true>> */
|
/**
|
||||||
private array $perTestTables = [];
|
* Cached class → test file resolution.
|
||||||
|
*
|
||||||
/** @var array<string, array<string, true>> */
|
* @var array<string, string|null>
|
||||||
private array $perTestInertiaComponents = [];
|
*/
|
||||||
|
|
||||||
/** @var array<string, true> */
|
|
||||||
private array $perTestUsesDatabase = [];
|
|
||||||
|
|
||||||
/** @var array<string, string|null> */
|
|
||||||
private array $classFileCache = [];
|
private array $classFileCache = [];
|
||||||
|
|
||||||
/** @var array<string, bool> */
|
|
||||||
private array $classUsesDatabaseCache = [];
|
|
||||||
|
|
||||||
private bool $active = false;
|
private bool $active = false;
|
||||||
|
|
||||||
private bool $captureCoverage = false;
|
|
||||||
|
|
||||||
private bool $driverChecked = false;
|
private bool $driverChecked = false;
|
||||||
|
|
||||||
private bool $driverAvailable = false;
|
private bool $driverAvailable = false;
|
||||||
|
|
||||||
private string $driver = 'none';
|
private string $driver = 'none';
|
||||||
|
|
||||||
private ?SourceScope $sourceScope = null;
|
|
||||||
|
|
||||||
public function activate(): void
|
public function activate(): void
|
||||||
{
|
|
||||||
$this->active = true;
|
|
||||||
$this->captureCoverage = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable per-test link tracking (tables, Inertia components, database
|
|
||||||
* usage, rendered views) without driving pcov/xdebug — for runs where
|
|
||||||
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
|
||||||
*/
|
|
||||||
public function activateLinkTracking(): void
|
|
||||||
{
|
{
|
||||||
$this->active = true;
|
$this->active = true;
|
||||||
}
|
}
|
||||||
@@ -71,10 +60,21 @@ final class Recorder
|
|||||||
if (function_exists('pcov\\start')) {
|
if (function_exists('pcov\\start')) {
|
||||||
$this->driver = 'pcov';
|
$this->driver = 'pcov';
|
||||||
$this->driverAvailable = true;
|
$this->driverAvailable = true;
|
||||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
} elseif (function_exists('xdebug_start_code_coverage')) {
|
||||||
$modes = \xdebug_info('mode');
|
// Xdebug is loaded. Probe whether coverage mode is active by
|
||||||
|
// attempting a start — it emits E_WARNING when the mode is off.
|
||||||
|
// We capture the warning via a temporary error handler.
|
||||||
|
$probeOk = true;
|
||||||
|
set_error_handler(static function () use (&$probeOk): bool {
|
||||||
|
$probeOk = false;
|
||||||
|
|
||||||
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
return true;
|
||||||
|
});
|
||||||
|
\xdebug_start_code_coverage();
|
||||||
|
restore_error_handler();
|
||||||
|
|
||||||
|
if ($probeOk) {
|
||||||
|
\xdebug_stop_code_coverage(false);
|
||||||
$this->driver = 'xdebug';
|
$this->driver = 'xdebug';
|
||||||
$this->driverAvailable = true;
|
$this->driverAvailable = true;
|
||||||
}
|
}
|
||||||
@@ -86,17 +86,16 @@ final class Recorder
|
|||||||
return $this->driverAvailable;
|
return $this->driverAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function driver(): string
|
||||||
|
{
|
||||||
|
$this->driverAvailable();
|
||||||
|
|
||||||
|
return $this->driver;
|
||||||
|
}
|
||||||
|
|
||||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||||
{
|
{
|
||||||
if (! $this->active) {
|
if (! $this->active || ! $this->driverAvailable()) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->currentTestFile !== null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,14 +107,6 @@ final class Recorder
|
|||||||
|
|
||||||
$this->currentTestFile = $file;
|
$this->currentTestFile = $file;
|
||||||
|
|
||||||
if ($this->classUsesDatabase($className)) {
|
|
||||||
$this->perTestUsesDatabase[$file] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->captureCoverage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
\pcov\start();
|
\pcov\start();
|
||||||
@@ -123,143 +114,39 @@ final class Recorder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Xdebug
|
||||||
\xdebug_start_code_coverage();
|
\xdebug_start_code_coverage();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function endTest(): void
|
public function endTest(): void
|
||||||
{
|
{
|
||||||
if (! $this->active || $this->currentTestFile === null) {
|
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
|
||||||
$this->currentTestFile = null;
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\stop();
|
\pcov\stop();
|
||||||
|
|
||||||
$scope = $this->sourceScope();
|
|
||||||
$filesToCollectCoverageFor = [];
|
|
||||||
|
|
||||||
foreach (\pcov\waiting() as $file) {
|
|
||||||
if (is_string($file) && $scope->contains($file)) {
|
|
||||||
$filesToCollectCoverageFor[] = $file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var array<string, mixed> $data */
|
/** @var array<string, mixed> $data */
|
||||||
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
|
$data = \pcov\collect(\pcov\inclusive);
|
||||||
|
|
||||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
|
||||||
} else {
|
} else {
|
||||||
/** @var array<string, mixed> $data */
|
/** @var array<string, mixed> $data */
|
||||||
$data = \xdebug_get_code_coverage();
|
$data = \xdebug_get_code_coverage();
|
||||||
|
// `true` resets Xdebug's internal buffer so the next `start()`
|
||||||
|
// does not accumulate earlier tests' coverage into the current
|
||||||
|
// one — otherwise the graph becomes progressively polluted.
|
||||||
\xdebug_stop_code_coverage(true);
|
\xdebug_stop_code_coverage(true);
|
||||||
|
|
||||||
$scope = $this->sourceScope();
|
|
||||||
|
|
||||||
foreach (array_keys($data) as $file) {
|
|
||||||
if (! $scope->contains($file)) {
|
|
||||||
unset($data[$file]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($coveredFiles as $sourceFile) {
|
foreach (array_keys($data) as $sourceFile) {
|
||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function linkSource(string $sourceFile): void
|
/**
|
||||||
{
|
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
||||||
if (! $this->active) {
|
*/
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->currentTestFile === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($sourceFile === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function classUsesDatabase(string $className): bool
|
|
||||||
{
|
|
||||||
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
|
|
||||||
return $this->classUsesDatabaseCache[$className];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! class_exists($className, false)) {
|
|
||||||
return $this->classUsesDatabaseCache[$className] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static $needles = [
|
|
||||||
'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
|
|
||||||
'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
|
|
||||||
'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
|
|
||||||
];
|
|
||||||
|
|
||||||
$reflection = new ReflectionClass($className);
|
|
||||||
|
|
||||||
do {
|
|
||||||
foreach (array_keys($reflection->getTraits()) as $traitName) {
|
|
||||||
if (isset($needles[$traitName])) {
|
|
||||||
return $this->classUsesDatabaseCache[$className] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$reflection = $reflection->getParentClass();
|
|
||||||
} while ($reflection !== false && ! $reflection->isInternal());
|
|
||||||
|
|
||||||
return $this->classUsesDatabaseCache[$className] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function linkTable(string $table): void
|
|
||||||
{
|
|
||||||
if (! $this->active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->currentTestFile === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($table === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function linkInertiaComponent(string $component): void
|
|
||||||
{
|
|
||||||
if (! $this->active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->currentTestFile === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($component === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, array<int, string>> */
|
|
||||||
public function perTestFiles(): array
|
public function perTestFiles(): array
|
||||||
{
|
{
|
||||||
$out = [];
|
$out = [];
|
||||||
@@ -271,40 +158,6 @@ final class Recorder
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, array<int, string>> */
|
|
||||||
public function perTestTables(): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($this->perTestTables as $testFile => $tables) {
|
|
||||||
$names = array_keys($tables);
|
|
||||||
sort($names);
|
|
||||||
$out[$testFile] = $names;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, array<int, string>> */
|
|
||||||
public function perTestInertiaComponents(): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($this->perTestInertiaComponents as $testFile => $components) {
|
|
||||||
$names = array_keys($components);
|
|
||||||
sort($names);
|
|
||||||
$out[$testFile] = $names;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, true> */
|
|
||||||
public function perTestUsesDatabase(): array
|
|
||||||
{
|
|
||||||
return $this->perTestUsesDatabase;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
||||||
{
|
{
|
||||||
if (array_key_exists($className, $this->classFileCache)) {
|
if (array_key_exists($className, $this->classFileCache)) {
|
||||||
@@ -325,67 +178,52 @@ final class Recorder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the file that *defines* the test class.
|
||||||
|
*
|
||||||
|
* Order of preference:
|
||||||
|
* 1. Pest's generated `$__filename` static — the original `*.php` file
|
||||||
|
* containing the `test()` calls (the eval'd class itself has no file).
|
||||||
|
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
|
||||||
|
* is intentionally more specific than `ReflectionMethod::getFileName()`
|
||||||
|
* (which would return the *trait* file for methods brought in via
|
||||||
|
* `uses SharedTestBehavior`).
|
||||||
|
*/
|
||||||
private function readPestFilename(string $className): ?string
|
private function readPestFilename(string $className): ?string
|
||||||
{
|
{
|
||||||
if (! class_exists($className, false)) {
|
if (! class_exists($className, false)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(property_exists($className, '__filename') && is_string($className::$__filename));
|
$reflection = new ReflectionClass($className);
|
||||||
|
|
||||||
return $className::$__filename;
|
if ($reflection->hasProperty('__filename')) {
|
||||||
|
$property = $reflection->getProperty('__filename');
|
||||||
|
|
||||||
|
if ($property->isStatic()) {
|
||||||
|
$value = $property->getValue();
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $reflection->getFileName();
|
||||||
|
|
||||||
|
return is_string($file) ? $file : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* Clears all captured state. Useful for long-running hosts (daemons,
|
||||||
* @return list<string>
|
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
|
||||||
|
* — without this, coverage from run N would bleed into run N+1.
|
||||||
*/
|
*/
|
||||||
private function filesWithExecutedLines(array $data): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($data as $file => $lines) {
|
|
||||||
if (! is_array($lines)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$covered = [];
|
|
||||||
foreach ($lines as $line => $count) {
|
|
||||||
if (is_int($count) && $count > 0) {
|
|
||||||
$covered[] = $line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($covered === []) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lineKeys = array_keys($lines);
|
|
||||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = $file;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sourceScope(): SourceScope
|
|
||||||
{
|
|
||||||
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
$this->perTestFiles = [];
|
$this->perTestFiles = [];
|
||||||
$this->perTestTables = [];
|
|
||||||
$this->perTestInertiaComponents = [];
|
|
||||||
$this->perTestUsesDatabase = [];
|
|
||||||
$this->classFileCache = [];
|
$this->classFileCache = [];
|
||||||
$this->classUsesDatabaseCache = [];
|
|
||||||
$this->sourceScope = null;
|
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
$this->captureCoverage = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,27 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Collects per-test status + message during the run so the graph can persist
|
||||||
|
* them for faithful replay. PHPUnit's own result cache discards messages
|
||||||
|
* during serialisation — this collector retains them.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class ResultCollector
|
final class ResultCollector
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
* @var array<string, array{status: int, message: string, time: float}>
|
||||||
*/
|
*/
|
||||||
private array $results = [];
|
private array $results = [];
|
||||||
|
|
||||||
private ?string $currentTestId = null;
|
private ?string $currentTestId = null;
|
||||||
|
|
||||||
private ?string $currentTestFile = null;
|
|
||||||
|
|
||||||
private ?float $startTime = null;
|
private ?float $startTime = null;
|
||||||
|
|
||||||
public function testPrepared(string $testId, ?string $testFile = null): void
|
public function testPrepared(string $testId): void
|
||||||
{
|
{
|
||||||
$this->currentTestId = $testId;
|
$this->currentTestId = $testId;
|
||||||
$this->currentTestFile = $testFile;
|
|
||||||
$this->startTime = microtime(true);
|
$this->startTime = microtime(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +34,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::success());
|
$this->record(0, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testFailed(string $message): void
|
public function testFailed(string $message): void
|
||||||
@@ -44,7 +43,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::failure($message));
|
$this->record(7, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testErrored(string $message): void
|
public function testErrored(string $message): void
|
||||||
@@ -53,7 +52,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::error($message));
|
$this->record(8, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSkipped(string $message): void
|
public function testSkipped(string $message): void
|
||||||
@@ -62,7 +61,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::skipped($message));
|
$this->record(1, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testIncomplete(string $message): void
|
public function testIncomplete(string $message): void
|
||||||
@@ -71,7 +70,7 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::incomplete($message));
|
$this->record(2, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRisky(string $message): void
|
public function testRisky(string $message): void
|
||||||
@@ -80,50 +79,25 @@ final class ResultCollector
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->record(TestStatus::risky($message));
|
$this->record(5, $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
|
* @return array<string, array{status: int, message: string, time: float}>
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function all(): array
|
||||||
{
|
{
|
||||||
return $this->results;
|
return $this->results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function recordAssertions(string $testId, int $assertions): void
|
|
||||||
{
|
|
||||||
if (isset($this->results[$testId])) {
|
|
||||||
$this->results[$testId]['assertions'] = $assertions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
|
|
||||||
*/
|
|
||||||
public function merge(array $results): void
|
|
||||||
{
|
|
||||||
foreach ($results as $testId => $result) {
|
|
||||||
$this->results[$testId] = $result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->results = [];
|
$this->results = [];
|
||||||
$this->currentTestId = null;
|
$this->currentTestId = null;
|
||||||
$this->currentTestFile = null;
|
|
||||||
$this->startTime = null;
|
$this->startTime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function finishTest(): void
|
private function record(int $status, string $message): void
|
||||||
{
|
|
||||||
$this->currentTestId = null;
|
|
||||||
$this->currentTestFile = null;
|
|
||||||
$this->startTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function record(TestStatus $status): void
|
|
||||||
{
|
{
|
||||||
if ($this->currentTestId === null) {
|
if ($this->currentTestId === null) {
|
||||||
return;
|
return;
|
||||||
@@ -133,17 +107,13 @@ final class ResultCollector
|
|||||||
? round(microtime(true) - $this->startTime, 3)
|
? round(microtime(true) - $this->startTime, 3)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
$existing = $this->results[$this->currentTestId] ?? null;
|
|
||||||
|
|
||||||
$this->results[$this->currentTestId] = [
|
$this->results[$this->currentTestId] = [
|
||||||
'status' => $status->asInt(),
|
'status' => $status,
|
||||||
'message' => $status->message(),
|
'message' => $message,
|
||||||
'time' => $time,
|
'time' => $time,
|
||||||
'assertions' => $existing['assertions'] ?? 0,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($this->currentTestFile !== null) {
|
$this->currentTestId = null;
|
||||||
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
|
$this->startTime = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use PHPUnit\TextUI\Configuration\Registry;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class SourceScope
|
|
||||||
{
|
|
||||||
/** @var array<string, bool> */
|
|
||||||
private array $containsCache = [];
|
|
||||||
|
|
||||||
private const array TOP_LEVEL_NOISE = [
|
|
||||||
'vendor',
|
|
||||||
'node_modules',
|
|
||||||
'.git',
|
|
||||||
'.idea',
|
|
||||||
'.vscode',
|
|
||||||
'.github',
|
|
||||||
'.pest',
|
|
||||||
'.phpunit.cache',
|
|
||||||
'.cache',
|
|
||||||
];
|
|
||||||
|
|
||||||
private const array NESTED_NOISE = [
|
|
||||||
'storage/framework',
|
|
||||||
'storage/logs',
|
|
||||||
'bootstrap/cache',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $includes Absolute, normalised directory paths.
|
|
||||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private readonly array $includes,
|
|
||||||
private readonly array $excludes,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function fromProjectRoot(string $projectRoot): self
|
|
||||||
{
|
|
||||||
$phpunitIncludes = [];
|
|
||||||
$phpunitExcludes = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$source = Registry::get()->source();
|
|
||||||
|
|
||||||
foreach ($source->includeDirectories() as $dir) {
|
|
||||||
$phpunitIncludes[] = self::normalise($dir->path());
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($source->excludeDirectories() as $dir) {
|
|
||||||
$phpunitExcludes[] = self::normalise($dir->path());
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
// Registry not initialized — fall back to project-root scanning.
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootIncludes = self::topLevelProjectDirs($projectRoot);
|
|
||||||
|
|
||||||
$includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
|
|
||||||
$excludes = array_values(array_unique([
|
|
||||||
...$phpunitExcludes,
|
|
||||||
...self::nestedNoiseDirs($projectRoot),
|
|
||||||
]));
|
|
||||||
|
|
||||||
if ($includes === []) {
|
|
||||||
$includes = [self::normalise($projectRoot)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self($includes, $excludes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
|
||||||
*/
|
|
||||||
public static function testPaths(): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$suites = Registry::get()->testSuite();
|
|
||||||
} catch (Throwable) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$out = [];
|
|
||||||
foreach ($suites as $suite) {
|
|
||||||
foreach ($suite->directories() as $directory) {
|
|
||||||
$out[] = self::normalise($directory->path());
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($suite->files() as $file) {
|
|
||||||
$out[] = self::normalise($file->path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($out));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function contains(string $absoluteFile): bool
|
|
||||||
{
|
|
||||||
if (isset($this->containsCache[$absoluteFile])) {
|
|
||||||
return $this->containsCache[$absoluteFile];
|
|
||||||
}
|
|
||||||
|
|
||||||
$real = @realpath($absoluteFile);
|
|
||||||
$candidate = $real === false ? $absoluteFile : $real;
|
|
||||||
$candidate = self::normalise($candidate);
|
|
||||||
|
|
||||||
foreach ($this->excludes as $excluded) {
|
|
||||||
if ($this->startsWithDir($candidate, $excluded)) {
|
|
||||||
return $this->containsCache[$absoluteFile] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->includes as $included) {
|
|
||||||
if ($this->startsWithDir($candidate, $included)) {
|
|
||||||
return $this->containsCache[$absoluteFile] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->containsCache[$absoluteFile] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function topLevelProjectDirs(string $projectRoot): array
|
|
||||||
{
|
|
||||||
$entries = @scandir($projectRoot);
|
|
||||||
|
|
||||||
if ($entries === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if ($entry === '.') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($entry !== '' && $entry[0] === '.') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
|
|
||||||
|
|
||||||
if (! is_dir($abs)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = self::normalise(@realpath($abs) ?: $abs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function nestedNoiseDirs(string $projectRoot): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach (self::NESTED_NOISE as $relative) {
|
|
||||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
|
||||||
$out[] = self::normalise(@realpath($abs) ?: $abs);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function normalise(string $path): string
|
|
||||||
{
|
|
||||||
return rtrim($path, '/\\');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function startsWithDir(string $candidate, string $dir): bool
|
|
||||||
{
|
|
||||||
if ($candidate === $dir) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class Storage
|
|
||||||
{
|
|
||||||
public static function tempDir(string $projectRoot): string
|
|
||||||
{
|
|
||||||
$home = self::homeDir();
|
|
||||||
|
|
||||||
if ($home === null) {
|
|
||||||
return $projectRoot
|
|
||||||
.DIRECTORY_SEPARATOR.'.pest'
|
|
||||||
.DIRECTORY_SEPARATOR.'tia';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $home
|
|
||||||
.DIRECTORY_SEPARATOR.'.pest'
|
|
||||||
.DIRECTORY_SEPARATOR.'tia'
|
|
||||||
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function purge(string $projectRoot): void
|
|
||||||
{
|
|
||||||
$dir = self::tempDir($projectRoot);
|
|
||||||
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::removeRecursive($dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function removeRecursive(string $dir): void
|
|
||||||
{
|
|
||||||
$entries = @scandir($dir);
|
|
||||||
|
|
||||||
if ($entries === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($entries as $entry) {
|
|
||||||
if ($entry === '.') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($entry === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
|
||||||
|
|
||||||
if (is_dir($path) && ! is_link($path)) {
|
|
||||||
self::removeRecursive($path);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
@unlink($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
@rmdir($dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function homeDir(): ?string
|
|
||||||
{
|
|
||||||
foreach (['HOME', 'USERPROFILE'] as $key) {
|
|
||||||
$value = getenv($key);
|
|
||||||
|
|
||||||
if (is_string($value) && $value !== '' && is_dir($value)) {
|
|
||||||
return rtrim($value, '/\\');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
|
|
||||||
*/
|
|
||||||
private static function projectKey(string $projectRoot): string
|
|
||||||
{
|
|
||||||
$origin = self::originIdentity($projectRoot);
|
|
||||||
|
|
||||||
$realpath = @realpath($projectRoot);
|
|
||||||
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);
|
|
||||||
|
|
||||||
$hash = substr(hash('sha256', $input), 0, 16);
|
|
||||||
$slug = self::slug(basename($projectRoot));
|
|
||||||
|
|
||||||
return $slug === '' ? $hash : $slug.'-'.$hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function originIdentity(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$url = self::rawOriginUrl($projectRoot);
|
|
||||||
|
|
||||||
if ($url === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// git@host:org/repo(.git)
|
|
||||||
if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
|
|
||||||
return strtolower($m[1].'/'.$m[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file
|
|
||||||
if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
|
||||||
return strtolower($m[1].'/'.$m[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return strtolower($url);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function rawOriginUrl(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
|
|
||||||
|
|
||||||
if (! is_file($config)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($config);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) {
|
|
||||||
return trim($match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function slug(string $name): string
|
|
||||||
{
|
|
||||||
$slug = strtolower($name);
|
|
||||||
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
|
||||||
|
|
||||||
return trim($slug, '-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class TableExtractor
|
|
||||||
{
|
|
||||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single (optionally quoted) identifier segment.
|
|
||||||
*/
|
|
||||||
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string> Sorted, deduped table names referenced by the
|
|
||||||
*/
|
|
||||||
public static function fromSql(string $sql): array
|
|
||||||
{
|
|
||||||
$trimmed = ltrim($sql);
|
|
||||||
|
|
||||||
if ($trimmed === '') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
|
||||||
|
|
||||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tables = [];
|
|
||||||
|
|
||||||
foreach ($matches[1] as $qualified) {
|
|
||||||
$name = self::unqualified($qualified);
|
|
||||||
|
|
||||||
if ($name === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (self::isSchemaMeta($name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tables[strtolower($name)] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = array_keys($tables);
|
|
||||||
sort($out);
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string> Table names referenced by `Schema::` calls,
|
|
||||||
*/
|
|
||||||
public static function fromMigrationSource(string $php): array
|
|
||||||
{
|
|
||||||
$tables = [];
|
|
||||||
|
|
||||||
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
|
||||||
|
|
||||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
|
||||||
foreach ($matches[1] as $i => $primary) {
|
|
||||||
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
|
||||||
|
|
||||||
$secondary = $matches[2][$i] ?? '';
|
|
||||||
if ($secondary !== '') {
|
|
||||||
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
|
||||||
|
|
||||||
$sqlPatterns = [
|
|
||||||
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
|
||||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
|
||||||
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
|
||||||
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($sqlPatterns as $pattern) {
|
|
||||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($matches[1] as $name) {
|
|
||||||
$lower = strtolower(self::unqualified($name));
|
|
||||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
|
||||||
$tables[$lower] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
|
|
||||||
foreach ($matches[1] as $name) {
|
|
||||||
$lower = strtolower(self::lastDottedSegment($name));
|
|
||||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
|
||||||
$tables[$lower] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = array_keys($tables);
|
|
||||||
sort($out);
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The table segment of a possibly schema-qualified identifier chain,
|
|
||||||
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
|
||||||
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
|
||||||
*/
|
|
||||||
private static function unqualified(string $qualified): string
|
|
||||||
{
|
|
||||||
$name = '';
|
|
||||||
|
|
||||||
foreach (explode('.', $qualified) as $segment) {
|
|
||||||
$segment = trim($segment, " \t\n\r\"`[]");
|
|
||||||
|
|
||||||
if ($segment === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::isSchemaMeta($segment)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = $segment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function lastDottedSegment(string $name): string
|
|
||||||
{
|
|
||||||
$position = strrpos($name, '.');
|
|
||||||
|
|
||||||
return $position === false ? $name : substr($name, $position + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isSchemaMeta(string $name): bool
|
|
||||||
{
|
|
||||||
$lower = strtolower($name);
|
|
||||||
|
|
||||||
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|
|
||||||
|| str_starts_with($lower, 'pg_')
|
|
||||||
|| str_starts_with($lower, 'information_schema');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class TableTracker
|
|
||||||
{
|
|
||||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
|
||||||
|
|
||||||
private const string MARKER = 'pest.tia.table-tracker-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)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $app->bound('db')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$app->instance(self::MARKER, true);
|
|
||||||
|
|
||||||
$listener = static function (object $query) use ($recorder): void {
|
|
||||||
if (! property_exists($query, 'sql')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var mixed $sql */
|
|
||||||
$sql = $query->sql;
|
|
||||||
|
|
||||||
if (! is_string($sql) || $sql === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (TableExtractor::fromSql($sql) as $table) {
|
|
||||||
$recorder->linkTable($table);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @var object $db */
|
|
||||||
$db = $app->make('db');
|
|
||||||
|
|
||||||
if (is_callable([$db, 'listen'])) {
|
|
||||||
/** @var callable $listen */
|
|
||||||
$listen = [$db, 'listen'];
|
|
||||||
$listen($listener);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $app->bound('events')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var object $events */
|
|
||||||
$events = $app->make('events');
|
|
||||||
|
|
||||||
if (! method_exists($events, 'listen')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
use Pest\TestSuite;
|
|
||||||
use PHPUnit\TextUI\Configuration\Registry;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the set of project-relative paths that are considered test files,
|
|
||||||
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
|
|
||||||
* configuration when no config file is present.
|
|
||||||
*
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class TestPaths
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
|
|
||||||
* @param list<string> $files Project-relative file paths.
|
|
||||||
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private array $directories,
|
|
||||||
private array $files,
|
|
||||||
private array $suffixes,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function fromProjectRoot(string $projectRoot): self
|
|
||||||
{
|
|
||||||
$directories = [];
|
|
||||||
$files = [];
|
|
||||||
$suffixes = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$configuration = Registry::get();
|
|
||||||
|
|
||||||
foreach ($configuration->testSuite() as $suite) {
|
|
||||||
foreach ($suite->directories() as $directory) {
|
|
||||||
$rel = self::toRelative($directory->path(), $projectRoot);
|
|
||||||
|
|
||||||
if ($rel !== null) {
|
|
||||||
$directories[] = $rel;
|
|
||||||
}
|
|
||||||
|
|
||||||
$suffixes[] = $directory->suffix();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($suite->files() as $file) {
|
|
||||||
$rel = self::toRelative($file->path(), $projectRoot);
|
|
||||||
|
|
||||||
if ($rel !== null) {
|
|
||||||
$files[] = $rel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($suffixes === []) {
|
|
||||||
foreach ($configuration->testSuffixes() as $suffix) {
|
|
||||||
$suffixes[] = $suffix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Throwable) {
|
|
||||||
// Registry not initialized — fall through to defaults.
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($suffixes === []) {
|
|
||||||
$suffixes = ['.php'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($directories === [] && $files === []) {
|
|
||||||
$fallback = self::testSuiteFallback($projectRoot);
|
|
||||||
|
|
||||||
if ($fallback !== null) {
|
|
||||||
$directories[] = $fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self(
|
|
||||||
array_values(array_unique($directories)),
|
|
||||||
array_values(array_unique($files)),
|
|
||||||
array_values(array_unique($suffixes)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isTestFile(string $relativePath): bool
|
|
||||||
{
|
|
||||||
if (in_array($relativePath, $this->files, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
|
|
||||||
|
|
||||||
if (! $matchesSuffix) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->directories as $dir) {
|
|
||||||
if ($dir === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (str_starts_with($relativePath, $dir.'/')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function toRelative(string $value, string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$real = @realpath($value);
|
|
||||||
$resolved = $real === false ? $value : $real;
|
|
||||||
|
|
||||||
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
|
||||||
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
|
||||||
|
|
||||||
if (! str_starts_with($resolved.'/', $root)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim(substr($resolved, strlen($root)), '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function testSuiteFallback(string $projectRoot): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$testPath = TestSuite::getInstance()->testPath;
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$real = @realpath($testPath);
|
|
||||||
$resolved = $real === false ? $testPath : $real;
|
|
||||||
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
|
|
||||||
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
|
|
||||||
|
|
||||||
if (! str_starts_with($resolved.'/', $root)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rtrim(substr($resolved, strlen($root)), '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,35 +7,51 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
|||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Pest\Browser\Support\BrowserTestIdentifier;
|
use Pest\Browser\Support\BrowserTestIdentifier;
|
||||||
use Pest\Factories\TestCaseFactory;
|
use Pest\Factories\TestCaseFactory;
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Watch patterns for frontend assets that affect browser tests.
|
||||||
|
*
|
||||||
|
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to
|
||||||
|
* auto-discover directories containing browser tests. Falls back to the
|
||||||
|
* `tests/Browser` convention when the plugin is absent.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Browser implements WatchDefault
|
final readonly class Browser implements WatchDefault
|
||||||
{
|
{
|
||||||
public function applicable(): bool
|
public function applicable(): bool
|
||||||
{
|
{
|
||||||
|
// Browser tests can exist in any PHP project. We only activate when
|
||||||
|
// there is an actual `tests/Browser` directory OR pest-plugin-browser
|
||||||
|
// is installed.
|
||||||
return class_exists(InstalledVersions::class)
|
return class_exists(InstalledVersions::class)
|
||||||
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
|
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
|
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath);
|
||||||
|
|
||||||
$globs = [
|
$globs = [
|
||||||
'resources/js/** !*.php',
|
'resources/js/**/*.js',
|
||||||
'resources/css/** !*.php',
|
'resources/js/**/*.ts',
|
||||||
'public/hot !*.php',
|
'resources/js/**/*.tsx',
|
||||||
'public/** !*.php',
|
'resources/js/**/*.jsx',
|
||||||
|
'resources/js/**/*.vue',
|
||||||
|
'resources/js/**/*.svelte',
|
||||||
|
'resources/css/**/*.css',
|
||||||
|
'resources/css/**/*.scss',
|
||||||
|
'resources/css/**/*.less',
|
||||||
|
// Vite / Webpack build output that browser tests may consume.
|
||||||
|
'public/build/**/*.js',
|
||||||
|
'public/build/**/*.css',
|
||||||
];
|
];
|
||||||
|
|
||||||
$patterns = [];
|
$patterns = [];
|
||||||
|
|
||||||
foreach ($globs as $glob) {
|
foreach ($globs as $glob) {
|
||||||
$patterns[$glob] = $browserTargets;
|
$patterns[$glob] = $browserDirs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $patterns;
|
return $patterns;
|
||||||
@@ -44,16 +60,19 @@ final readonly class Browser implements WatchDefault
|
|||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
|
private function detectBrowserTestDirs(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
$targets = [];
|
$dirs = [];
|
||||||
|
|
||||||
$candidate = $testPath.'/Browser';
|
$candidate = $testPath.'/Browser';
|
||||||
|
|
||||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
||||||
$targets[] = $candidate;
|
$dirs[] = $candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
||||||
|
// is installed to find tests using `visit()` outside the conventional
|
||||||
|
// Browser/ folder.
|
||||||
if (class_exists(BrowserTestIdentifier::class)) {
|
if (class_exists(BrowserTestIdentifier::class)) {
|
||||||
$repo = TestSuite::getInstance()->tests;
|
$repo = TestSuite::getInstance()->tests;
|
||||||
|
|
||||||
@@ -66,10 +85,10 @@ final readonly class Browser implements WatchDefault
|
|||||||
|
|
||||||
foreach ($factory->methods as $method) {
|
foreach ($factory->methods as $method) {
|
||||||
if (BrowserTestIdentifier::isBrowserTest($method)) {
|
if (BrowserTestIdentifier::isBrowserTest($method)) {
|
||||||
$rel = self::fileRelative($projectRoot, $filename);
|
$rel = $this->fileRelative($projectRoot, $filename);
|
||||||
|
|
||||||
if ($rel !== null) {
|
if ($rel !== null) {
|
||||||
$targets[] = $rel;
|
$dirs[] = dirname($rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@@ -78,10 +97,10 @@ final readonly class Browser implements WatchDefault
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values(array_unique($targets));
|
return array_values(array_unique($dirs === [] ? [$testPath] : $dirs));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function fileRelative(string $projectRoot, string $path): ?string
|
private function fileRelative(string $projectRoot, string $path): ?string
|
||||||
{
|
{
|
||||||
$real = @realpath($path);
|
$real = @realpath($path);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Watch patterns for Inertia.js projects (Laravel or otherwise).
|
||||||
|
*
|
||||||
|
* Inertia bridges PHP controllers with JS/TS page components. A change to
|
||||||
|
* a React / Vue / Svelte page can break assertions in browser tests or
|
||||||
|
* Inertia-specific feature tests.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Inertia implements WatchDefault
|
final readonly class Inertia implements WatchDefault
|
||||||
@@ -21,8 +26,28 @@ final readonly class Inertia implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
|
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser')
|
||||||
|
? $testPath.'/Browser'
|
||||||
|
: $testPath;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'resources/js/** !*.php' => [$testPath],
|
// Inertia page components (React / Vue / Svelte).
|
||||||
|
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
|
||||||
|
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
|
||||||
|
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
|
||||||
|
'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir],
|
||||||
|
|
||||||
|
// Shared layouts / components consumed by pages.
|
||||||
|
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
||||||
|
'resources/js/Layouts/**/*.tsx' => [$browserDir],
|
||||||
|
'resources/js/Components/**/*.vue' => [$browserDir],
|
||||||
|
'resources/js/Components/**/*.tsx' => [$browserDir],
|
||||||
|
|
||||||
|
// SSR entry point.
|
||||||
|
'resources/js/ssr.js' => [$browserDir],
|
||||||
|
'resources/js/ssr.ts' => [$browserDir],
|
||||||
|
'resources/js/app.js' => [$browserDir],
|
||||||
|
'resources/js/app.ts' => [$browserDir],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Watch patterns for Laravel projects.
|
||||||
|
*
|
||||||
|
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
|
||||||
|
* `Prepared` event where TIA's coverage window opens). That means PHP files
|
||||||
|
* loaded during boot — config, routes, service providers, migrations — are
|
||||||
|
* invisible to the coverage driver. Watch patterns are the only way to
|
||||||
|
* track them.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Laravel implements WatchDefault
|
final readonly class Laravel implements WatchDefault
|
||||||
@@ -20,22 +27,55 @@ final readonly class Laravel implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
|
$featurePath = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Feature')
|
||||||
|
? $testPath.'/Feature'
|
||||||
|
: $testPath;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
// Config — loaded during app boot (setUp), invisible to coverage.
|
||||||
|
// Affects both Feature and Unit: Pest.php commonly binds fakes
|
||||||
|
// and seeds DB based on config values.
|
||||||
|
'config/*.php' => [$testPath],
|
||||||
|
'config/**/*.php' => [$testPath],
|
||||||
|
|
||||||
|
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
||||||
|
'routes/*.php' => [$featurePath],
|
||||||
|
'routes/**/*.php' => [$featurePath],
|
||||||
|
|
||||||
|
// Service providers / bootstrap — loaded during boot, affect
|
||||||
|
// bindings, middleware, event listeners, scheduled tasks.
|
||||||
|
'bootstrap/app.php' => [$testPath],
|
||||||
|
'bootstrap/providers.php' => [$testPath],
|
||||||
|
|
||||||
|
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
|
||||||
|
// setUp. Schema changes can break any test that touches DB.
|
||||||
'database/migrations/**/*.php' => [$testPath],
|
'database/migrations/**/*.php' => [$testPath],
|
||||||
|
|
||||||
'storage/fixtures/**/*' => [$testPath],
|
// Seeders — often run globally via Pest.php beforeEach.
|
||||||
|
'database/seeders/**/*.php' => [$testPath],
|
||||||
|
|
||||||
'app/** !*.php' => [$testPath],
|
// Factories — loaded lazily but still PHP that coverage may miss
|
||||||
|
// if the factory file was already autoloaded before Prepared.
|
||||||
|
'database/factories/**/*.php' => [$testPath],
|
||||||
|
|
||||||
'resources/views/**' => [$testPath],
|
// Blade templates — compiled to cache, source file not executed.
|
||||||
|
'resources/views/**/*.blade.php' => [$featurePath],
|
||||||
|
|
||||||
'lang/**' => [$testPath],
|
// Translations — JSON translations read via file_get_contents,
|
||||||
'resources/lang/**' => [$testPath],
|
// PHP translations loaded via include (but during boot).
|
||||||
|
'lang/**/*.php' => [$featurePath],
|
||||||
|
'lang/**/*.json' => [$featurePath],
|
||||||
|
'resources/lang/**/*.php' => [$featurePath],
|
||||||
|
'resources/lang/**/*.json' => [$featurePath],
|
||||||
|
|
||||||
'vite.config.* !*.php' => [$testPath],
|
// Build tool config — affects compiled assets consumed by
|
||||||
'webpack.mix.* !*.php' => [$testPath],
|
// browser and Inertia tests.
|
||||||
'tailwind.config.* !*.php' => [$testPath],
|
'vite.config.js' => [$featurePath],
|
||||||
'postcss.config.* !*.php' => [$testPath],
|
'vite.config.ts' => [$featurePath],
|
||||||
|
'webpack.mix.js' => [$featurePath],
|
||||||
|
'tailwind.config.js' => [$featurePath],
|
||||||
|
'tailwind.config.ts' => [$featurePath],
|
||||||
|
'postcss.config.js' => [$featurePath],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Watch patterns for projects using Livewire.
|
||||||
|
*
|
||||||
|
* Livewire components pair a PHP class with a Blade view. A view change can
|
||||||
|
* break rendering or assertions in feature / browser tests even though the
|
||||||
|
* PHP side is untouched.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Livewire implements WatchDefault
|
final readonly class Livewire implements WatchDefault
|
||||||
@@ -21,10 +26,11 @@ final readonly class Livewire implements WatchDefault
|
|||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
// Livewire views live alongside Blade views or in a dedicated dir.
|
||||||
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
'resources/views/livewire/**/*.blade.php' => [$testPath],
|
||||||
'resources/views/components/**/*.blade.php' => [$testPath],
|
'resources/views/components/**/*.blade.php' => [$testPath],
|
||||||
'resources/views/pages/**/*.blade.php' => [$testPath],
|
|
||||||
|
|
||||||
|
// Livewire JS interop / Alpine plugins.
|
||||||
'resources/js/**/*.js' => [$testPath],
|
'resources/js/**/*.js' => [$testPath],
|
||||||
'resources/js/**/*.ts' => [$testPath],
|
'resources/js/**/*.ts' => [$testPath],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Baseline watch patterns for any PHP project.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Php implements WatchDefault
|
final readonly class Php implements WatchDefault
|
||||||
@@ -18,20 +18,35 @@ final readonly class Php implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
|
// NOTE: composer.json / composer.lock changes are caught by the
|
||||||
|
// fingerprint (which hashes composer.lock). PHP files are tracked by
|
||||||
|
// the coverage driver. Only non-PHP, non-fingerprinted files that
|
||||||
|
// can silently alter test behaviour belong here.
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
// Environment files — can change DB drivers, feature flags,
|
||||||
|
// queue connections, etc. Not PHP, not fingerprinted.
|
||||||
'.env' => [$testPath],
|
'.env' => [$testPath],
|
||||||
'.env.testing' => [$testPath],
|
'.env.testing' => [$testPath],
|
||||||
'.env.local' => [$testPath],
|
|
||||||
'.env.*.local' => [$testPath],
|
|
||||||
|
|
||||||
|
// Docker / CI — can affect integration test infrastructure.
|
||||||
'docker-compose.yml' => [$testPath],
|
'docker-compose.yml' => [$testPath],
|
||||||
'docker-compose.yaml' => [$testPath],
|
'docker-compose.yaml' => [$testPath],
|
||||||
|
|
||||||
'phpunit.xml*' => [$testPath],
|
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
|
||||||
|
// phpunit.xml.dist and other XML overrides are not individually
|
||||||
|
// tracked by the coverage driver.
|
||||||
|
'phpunit.xml.dist' => [$testPath],
|
||||||
|
|
||||||
$testPath.'/Fixtures/**/*' => [$testPath],
|
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
||||||
$testPath.'/**/Fixtures/**/*' => [$testPath],
|
// assertions. A fixture change can flip a test result.
|
||||||
|
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
||||||
|
$testPath.'/Fixtures/**/*.csv' => [$testPath],
|
||||||
|
$testPath.'/Fixtures/**/*.xml' => [$testPath],
|
||||||
|
$testPath.'/Fixtures/**/*.txt' => [$testPath],
|
||||||
|
|
||||||
|
// Pest snapshots — external edits to snapshot files invalidate
|
||||||
|
// snapshot assertions.
|
||||||
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
|
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
use Composer\InstalledVersions;
|
use Composer\InstalledVersions;
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Watch patterns for Symfony projects.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class Symfony implements WatchDefault
|
final readonly class Symfony implements WatchDefault
|
||||||
@@ -20,23 +21,55 @@ final readonly class Symfony implements WatchDefault
|
|||||||
|
|
||||||
public function defaults(string $projectRoot, string $testPath): array
|
public function defaults(string $projectRoot, string $testPath): array
|
||||||
{
|
{
|
||||||
|
// Symfony boots the kernel in setUp() (before the coverage window).
|
||||||
|
// PHP config, routes, kernel, and migrations are loaded during boot
|
||||||
|
// and invisible to the coverage driver. Same reasoning as Laravel.
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'config/** !*.php' => [$testPath],
|
// Config — YAML, XML, and PHP. All loaded during kernel boot.
|
||||||
'config/routes/** !*.php' => [$testPath],
|
'config/*.yaml' => [$testPath],
|
||||||
|
'config/*.yml' => [$testPath],
|
||||||
|
'config/*.php' => [$testPath],
|
||||||
|
'config/*.xml' => [$testPath],
|
||||||
|
'config/**/*.yaml' => [$testPath],
|
||||||
|
'config/**/*.yml' => [$testPath],
|
||||||
|
'config/**/*.php' => [$testPath],
|
||||||
|
'config/**/*.xml' => [$testPath],
|
||||||
|
|
||||||
|
// Routes — loaded during boot.
|
||||||
|
'config/routes/*.yaml' => [$testPath],
|
||||||
|
'config/routes/*.php' => [$testPath],
|
||||||
|
'config/routes/*.xml' => [$testPath],
|
||||||
|
'config/routes/**/*.yaml' => [$testPath],
|
||||||
|
|
||||||
|
// Kernel / bootstrap — loaded during boot.
|
||||||
|
'src/Kernel.php' => [$testPath],
|
||||||
|
|
||||||
|
// Migrations — run during setUp (before coverage window).
|
||||||
'migrations/**/*.php' => [$testPath],
|
'migrations/**/*.php' => [$testPath],
|
||||||
'src/Migrations/**/*.php' => [$testPath],
|
|
||||||
|
|
||||||
'templates/** !*.php' => [$testPath],
|
// Twig templates — compiled, source not PHP-executed.
|
||||||
|
'templates/**/*.html.twig' => [$testPath],
|
||||||
|
'templates/**/*.twig' => [$testPath],
|
||||||
|
|
||||||
'translations/** !*.php' => [$testPath],
|
// Translations (YAML / XLF / XLIFF).
|
||||||
|
'translations/**/*.yaml' => [$testPath],
|
||||||
|
'translations/**/*.yml' => [$testPath],
|
||||||
|
'translations/**/*.xlf' => [$testPath],
|
||||||
|
'translations/**/*.xliff' => [$testPath],
|
||||||
|
|
||||||
|
// Doctrine XML/YAML mappings.
|
||||||
'config/doctrine/**/*.xml' => [$testPath],
|
'config/doctrine/**/*.xml' => [$testPath],
|
||||||
'config/doctrine/**/*.yaml' => [$testPath],
|
'config/doctrine/**/*.yaml' => [$testPath],
|
||||||
|
|
||||||
|
// Webpack Encore / asset-mapper config + frontend sources.
|
||||||
'webpack.config.js' => [$testPath],
|
'webpack.config.js' => [$testPath],
|
||||||
'importmap.php' => [$testPath],
|
'importmap.php' => [$testPath],
|
||||||
'assets/** !*.php' => [$testPath],
|
'assets/**/*.js' => [$testPath],
|
||||||
|
'assets/**/*.ts' => [$testPath],
|
||||||
|
'assets/**/*.vue' => [$testPath],
|
||||||
|
'assets/**/*.css' => [$testPath],
|
||||||
|
'assets/**/*.scss' => [$testPath],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of file-watch patterns that apply when a particular framework,
|
||||||
|
* library or project layout is detected.
|
||||||
|
*
|
||||||
|
* Each implementation probes for the presence of the tool it covers
|
||||||
|
* (`applicable`) and returns glob → test-directory mappings (`defaults`)
|
||||||
|
* that are merged into `WatchPatterns`.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface WatchDefault
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Whether this default set applies to the current project.
|
||||||
|
*/
|
||||||
|
public function applicable(): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>> glob → list of project-relative test dirs
|
||||||
|
*/
|
||||||
|
public function defaults(string $projectRoot, string $testPath): array;
|
||||||
|
}
|
||||||
@@ -4,15 +4,28 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Contracts\WatchDefault;
|
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Maps non-PHP file globs to the test directories they should invalidate.
|
||||||
|
*
|
||||||
|
* Coverage drivers only see `.php` files. Frontend assets, config files,
|
||||||
|
* Blade templates, routes and environment files are invisible to the graph.
|
||||||
|
* Watch patterns bridge the gap: when a changed file matches a glob, every
|
||||||
|
* test under the associated directory is marked as affected.
|
||||||
|
*
|
||||||
|
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
|
||||||
|
* each implementation probes the current project and contributes patterns
|
||||||
|
* when applicable. Users extend via `pest()->tia()->watch(…)`.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class WatchPatterns
|
final class WatchPatterns
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
* All known default providers, in evaluation order.
|
||||||
|
*
|
||||||
* @var array<int, class-string<WatchDefault>>
|
* @var array<int, class-string<WatchDefault>>
|
||||||
*/
|
*/
|
||||||
private const array DEFAULTS = [
|
private const array DEFAULTS = [
|
||||||
@@ -24,26 +37,17 @@ final class WatchPatterns
|
|||||||
WatchDefaults\Browser::class,
|
WatchDefaults\Browser::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
|
* @var array<string, array<int, string>> glob → list of project-relative test dirs
|
||||||
*/
|
*/
|
||||||
private array $patterns = [];
|
private array $patterns = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
|
* Probes every registered `WatchDefault` and merges the patterns of
|
||||||
|
* those that apply. Called once during Tia plugin boot, after BootFiles
|
||||||
|
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
|
||||||
|
* calls are already in `$this->patterns`).
|
||||||
*/
|
*/
|
||||||
private array $parsed = [];
|
|
||||||
|
|
||||||
private bool $enabled = false;
|
|
||||||
|
|
||||||
private bool $locally = false;
|
|
||||||
|
|
||||||
private bool $filtered = false;
|
|
||||||
|
|
||||||
private bool $baselined = false;
|
|
||||||
|
|
||||||
public function useDefaults(string $projectRoot): void
|
public function useDefaults(string $projectRoot): void
|
||||||
{
|
{
|
||||||
$testPath = TestSuite::getInstance()->testPath;
|
$testPath = TestSuite::getInstance()->testPath;
|
||||||
@@ -55,30 +59,36 @@ final class WatchPatterns
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
|
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
|
||||||
$this->patterns[$key] = array_values(array_unique(
|
$this->patterns[$glob] = array_values(array_unique(
|
||||||
array_merge($this->patterns[$key] ?? [], $dirs),
|
array_merge($this->patterns[$glob] ?? [], $dirs),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $patterns pattern key → project-relative test dir/file
|
* Adds user-defined patterns. Merges with existing entries so a single
|
||||||
|
* glob can map to multiple directories.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $patterns glob → project-relative test dir
|
||||||
*/
|
*/
|
||||||
public function add(array $patterns): void
|
public function add(array $patterns): void
|
||||||
{
|
{
|
||||||
foreach ($patterns as $key => $dir) {
|
foreach ($patterns as $glob => $dir) {
|
||||||
$this->patterns[$key] = array_values(array_unique(
|
$this->patterns[$glob] = array_values(array_unique(
|
||||||
array_merge($this->patterns[$key] ?? [], [$dir]),
|
array_merge($this->patterns[$glob] ?? [], [$dir]),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Returns all test directories whose watch patterns match at least one of
|
||||||
|
* the given changed files.
|
||||||
|
*
|
||||||
* @param string $projectRoot Absolute path.
|
* @param string $projectRoot Absolute path.
|
||||||
* @param array<int, string> $changedFiles Project-relative paths.
|
* @param array<int, string> $changedFiles Project-relative paths.
|
||||||
* @return array<int, string> Project-relative test dirs/files.
|
* @return array<int, string> Project-relative test directories.
|
||||||
*/
|
*/
|
||||||
public function matchedDirectories(string $projectRoot, array $changedFiles): array
|
public function matchedDirectories(string $projectRoot, array $changedFiles): array
|
||||||
{
|
{
|
||||||
@@ -89,13 +99,11 @@ final class WatchPatterns
|
|||||||
$matched = [];
|
$matched = [];
|
||||||
|
|
||||||
foreach ($changedFiles as $file) {
|
foreach ($changedFiles as $file) {
|
||||||
foreach ($this->patterns as $key => $dirs) {
|
foreach ($this->patterns as $glob => $dirs) {
|
||||||
if (! $this->keyMatches($key, $file)) {
|
if ($this->globMatches($glob, $file)) {
|
||||||
continue;
|
foreach ($dirs as $dir) {
|
||||||
}
|
$matched[$dir] = true;
|
||||||
|
}
|
||||||
foreach ($dirs as $dir) {
|
|
||||||
$matched[$dir] = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +112,10 @@ final class WatchPatterns
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $directories Project-relative dirs/files.
|
* Given the affected directories, returns every test file in the graph
|
||||||
|
* that lives under one of those directories.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $directories Project-relative dirs.
|
||||||
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
* @param array<int, string> $allTestFiles Project-relative test files from graph.
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@@ -117,14 +128,8 @@ final class WatchPatterns
|
|||||||
$affected = [];
|
$affected = [];
|
||||||
|
|
||||||
foreach ($allTestFiles as $testFile) {
|
foreach ($allTestFiles as $testFile) {
|
||||||
foreach ($directories as $target) {
|
foreach ($directories as $dir) {
|
||||||
if ($testFile === $target) {
|
$prefix = rtrim($dir, '/').'/';
|
||||||
$affected[] = $testFile;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prefix = rtrim($target, '/').'/';
|
|
||||||
|
|
||||||
if (str_starts_with($testFile, $prefix)) {
|
if (str_starts_with($testFile, $prefix)) {
|
||||||
$affected[] = $testFile;
|
$affected[] = $testFile;
|
||||||
@@ -137,146 +142,16 @@ final class WatchPatterns
|
|||||||
return $affected;
|
return $affected;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function markEnabled(): void
|
|
||||||
{
|
|
||||||
$this->enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isEnabled(): bool
|
|
||||||
{
|
|
||||||
return $this->enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markLocally(): void
|
|
||||||
{
|
|
||||||
$this->locally = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isLocally(): bool
|
|
||||||
{
|
|
||||||
return $this->locally;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markFiltered(): void
|
|
||||||
{
|
|
||||||
$this->filtered = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isFiltered(): bool
|
|
||||||
{
|
|
||||||
return $this->filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markBaselined(): void
|
|
||||||
{
|
|
||||||
$this->baselined = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBaselined(): bool
|
|
||||||
{
|
|
||||||
return $this->baselined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->patterns = [];
|
$this->patterns = [];
|
||||||
$this->parsed = [];
|
|
||||||
$this->enabled = false;
|
|
||||||
$this->locally = false;
|
|
||||||
$this->filtered = false;
|
|
||||||
$this->baselined = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function keyMatches(string $key, string $file): bool
|
|
||||||
{
|
|
||||||
$rule = $this->parse($key);
|
|
||||||
|
|
||||||
if (! $this->globMatches($rule['include'], $file)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = str_replace('\\', '/', $file);
|
|
||||||
|
|
||||||
if ($this->touchesVcs($file)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($rule['excludes'] as $exclude) {
|
|
||||||
if ($this->excludeMatches($exclude, $file)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
|
* Matches a project-relative file against a glob pattern.
|
||||||
|
*
|
||||||
|
* Supports `*` (single segment), `**` (any depth) and `?`.
|
||||||
*/
|
*/
|
||||||
private function parse(string $key): array
|
|
||||||
{
|
|
||||||
if (isset($this->parsed[$key])) {
|
|
||||||
return $this->parsed[$key];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokens = preg_split('/\s+/', trim($key)) ?: [];
|
|
||||||
|
|
||||||
$include = '';
|
|
||||||
$excludes = [];
|
|
||||||
|
|
||||||
foreach ($tokens as $token) {
|
|
||||||
if ($token === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($token[0] === '!') {
|
|
||||||
$excludes[] = substr($token, 1);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($include === '') {
|
|
||||||
$include = $token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->parsed[$key] = [
|
|
||||||
'include' => $include,
|
|
||||||
'excludes' => $excludes,
|
|
||||||
'allowDotfiles' => $this->patternTargetsDotfiles($include),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function patternTargetsDotfiles(string $pattern): bool
|
|
||||||
{
|
|
||||||
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function touchesVcs(string $file): bool
|
|
||||||
{
|
|
||||||
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function touchesDotfile(string $file): bool
|
|
||||||
{
|
|
||||||
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function excludeMatches(string $exclude, string $file): bool
|
|
||||||
{
|
|
||||||
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
|
|
||||||
|
|
||||||
if ($this->globMatches($pattern, $file)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->globMatches($exclude, basename($file));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function globMatches(string $pattern, string $file): bool
|
private function globMatches(string $pattern, string $file): bool
|
||||||
{
|
{
|
||||||
$pattern = str_replace('\\', '/', $pattern);
|
$pattern = str_replace('\\', '/', $pattern);
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Restarters;
|
|
||||||
|
|
||||||
use Pest\Contracts\Restarter;
|
|
||||||
use Pest\Plugins\Tia;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class PcovRestarter implements Restarter
|
|
||||||
{
|
|
||||||
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
*/
|
|
||||||
public function maybeRestart(string $projectRoot, array $arguments): void
|
|
||||||
{
|
|
||||||
if (! extension_loaded('pcov')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getenv(self::ENV_RESTARTED) === '1') {
|
|
||||||
putenv(self::ENV_RESTARTED);
|
|
||||||
unset($_ENV[self::ENV_RESTARTED]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Tia::isEnabledForRun($arguments)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$desired = $this->normalise($projectRoot);
|
|
||||||
$current = $this->normalise((string) ini_get('pcov.directory'));
|
|
||||||
|
|
||||||
if ($current === $desired) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->restart($projectRoot, $arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
*/
|
|
||||||
private function restart(string $projectRoot, array $arguments): void
|
|
||||||
{
|
|
||||||
$env = $this->inheritEnv();
|
|
||||||
$env[self::ENV_RESTARTED] = '1';
|
|
||||||
|
|
||||||
$command = array_merge(
|
|
||||||
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
|
|
||||||
array_values($arguments),
|
|
||||||
);
|
|
||||||
|
|
||||||
$proc = @proc_open(
|
|
||||||
$command,
|
|
||||||
[STDIN, STDOUT, STDERR],
|
|
||||||
$pipes,
|
|
||||||
null,
|
|
||||||
$env,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! is_resource($proc)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$exitCode = proc_close($proc);
|
|
||||||
|
|
||||||
exit($exitCode === -1 ? 1 : $exitCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function inheritEnv(): array
|
|
||||||
{
|
|
||||||
$env = [];
|
|
||||||
|
|
||||||
foreach (getenv() as $name => $value) {
|
|
||||||
$env[$name] = $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $env;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalise(string $path): string
|
|
||||||
{
|
|
||||||
return rtrim($path, '/\\');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Restarters;
|
|
||||||
|
|
||||||
use Composer\XdebugHandler\XdebugHandler;
|
|
||||||
use Pest\Contracts\Restarter;
|
|
||||||
use Pest\Plugins\Tia;
|
|
||||||
use Pest\Plugins\Tia\Fingerprint;
|
|
||||||
use Pest\Plugins\Tia\Graph;
|
|
||||||
use Pest\Plugins\Tia\Storage;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final class XdebugRestarter implements Restarter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
*/
|
|
||||||
public function maybeRestart(string $projectRoot, array $arguments): void
|
|
||||||
{
|
|
||||||
if (! class_exists(XdebugHandler::class)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! extension_loaded('xdebug')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->xdebugIsCoverageOnly()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->runLooksDroppable($arguments, $projectRoot)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
new XdebugHandler('pest')->check();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xdebugIsCoverageOnly(): bool
|
|
||||||
{
|
|
||||||
if (! function_exists('xdebug_info')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$modes = @xdebug_info('mode');
|
|
||||||
|
|
||||||
if (! is_array($modes)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$modes = array_values(array_filter($modes, is_string(...)));
|
|
||||||
|
|
||||||
if ($modes === []) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $modes === ['coverage'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $arguments
|
|
||||||
*/
|
|
||||||
private function runLooksDroppable(array $arguments, string $projectRoot): bool
|
|
||||||
{
|
|
||||||
foreach ($arguments as $value) {
|
|
||||||
if ($value === '--coverage'
|
|
||||||
|| str_starts_with($value, '--coverage=')
|
|
||||||
|| str_starts_with($value, '--coverage-')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value === '--fresh') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Tia::isEnabledForRun($arguments)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->tiaWillReplay($projectRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tiaWillReplay(string $projectRoot): bool
|
|
||||||
{
|
|
||||||
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
|
|
||||||
|
|
||||||
if (! is_file($path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$json = @file_get_contents($path);
|
|
||||||
|
|
||||||
if ($json === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$graph = Graph::decode($json, $projectRoot);
|
|
||||||
|
|
||||||
if (! $graph instanceof Graph) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Fingerprint::structuralMatches(
|
|
||||||
$graph->fingerprint(),
|
|
||||||
Fingerprint::compute($projectRoot),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,20 +15,15 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Indicates if the TeamCity logger has already been registered.
|
|
||||||
*/
|
|
||||||
private static bool $registered = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Configured Subscriber instance.
|
* Creates a new Configured Subscriber instance.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly InputInterface $input,
|
private InputInterface $input,
|
||||||
private readonly OutputInterface $output,
|
private OutputInterface $output,
|
||||||
private readonly TestSuite $testSuite,
|
private TestSuite $testSuite,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,16 +31,10 @@ final class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
|||||||
*/
|
*/
|
||||||
public function notify(Configured $event): void
|
public function notify(Configured $event): void
|
||||||
{
|
{
|
||||||
if (self::$registered) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->input->hasParameterOption('--teamcity')) {
|
if (! $this->input->hasParameterOption('--teamcity')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$registered = true;
|
|
||||||
|
|
||||||
$flowId = getenv('FLOW_ID');
|
$flowId = getenv('FLOW_ID');
|
||||||
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
|
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Code\TestMethod;
|
|
||||||
use PHPUnit\Event\Test\Finished;
|
|
||||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(Finished $event): void
|
|
||||||
{
|
|
||||||
$test = $event->test();
|
|
||||||
|
|
||||||
if ($test instanceof TestMethod) {
|
|
||||||
$this->collector->recordAssertions(
|
|
||||||
$test->className().'::'.$test->methodName(),
|
|
||||||
$event->numberOfAssertionsPerformed(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->collector->finishTest();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,12 @@ use PHPUnit\Event\Test\Finished;
|
|||||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Stops PCOV collection after each test and merges the covered files into the
|
||||||
|
* TIA recorder's aggregate map. No-op unless the recorder is active.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class EnsureTiaEnds implements FinishedSubscriber
|
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(private Recorder $recorder) {}
|
public function __construct(private Recorder $recorder) {}
|
||||||
|
|
||||||
+4
-1
@@ -10,9 +10,12 @@ use PHPUnit\Event\Test\Prepared;
|
|||||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Starts PCOV collection before each test. No-op unless the TIA recorder was
|
||||||
|
* activated by the `--tia` plugin.
|
||||||
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
final readonly class EnsureTiaStarts implements PreparedSubscriber
|
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
|
||||||
{
|
{
|
||||||
public function __construct(private Recorder $recorder) {}
|
public function __construct(private Recorder $recorder) {}
|
||||||
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Exceptions\TiaRequiresPestTests;
|
|
||||||
use Pest\Panic;
|
|
||||||
use Pest\Plugins\Tia\Recorder;
|
|
||||||
use PHPUnit\Event\Code\TestMethod;
|
|
||||||
use PHPUnit\Event\Test\Prepared;
|
|
||||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private Recorder $recorder) {}
|
|
||||||
|
|
||||||
public function notify(Prepared $event): void
|
|
||||||
{
|
|
||||||
if (! $this->recorder->isActive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$test = $event->test();
|
|
||||||
|
|
||||||
if (! $test instanceof TestMethod) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$className = $test->className();
|
|
||||||
|
|
||||||
if (! class_exists($className, false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method_exists($className, '__initializeTestCase')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Panic::with(new TiaRequiresPestTests($className, $test->file()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Test\Errored;
|
|
||||||
use PHPUnit\Event\Test\ErroredSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(Errored $event): void
|
|
||||||
{
|
|
||||||
$this->collector->testErrored($event->throwable()->message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Test\Failed;
|
|
||||||
use PHPUnit\Event\Test\FailedSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(Failed $event): void
|
|
||||||
{
|
|
||||||
$this->collector->testFailed($event->throwable()->message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
|
||||||
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(MarkedIncomplete $event): void
|
|
||||||
{
|
|
||||||
$this->collector->testIncomplete($event->throwable()->message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Test\Passed;
|
|
||||||
use PHPUnit\Event\Test\PassedSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(Passed $event): void
|
|
||||||
{
|
|
||||||
$this->collector->testPassed();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Test\ConsideredRisky;
|
|
||||||
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(ConsideredRisky $event): void
|
|
||||||
{
|
|
||||||
$this->collector->testRisky($event->message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Pest\Subscribers;
|
|
||||||
|
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
|
||||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
final readonly class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
|
|
||||||
{
|
|
||||||
public function __construct(private ResultCollector $collector) {}
|
|
||||||
|
|
||||||
public function notify(Skipped $event): void
|
|
||||||
{
|
|
||||||
$this->collector->testSkipped($event->message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user