mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
157 Commits
v4.7.1
...
7b6415ccd0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b6415ccd0 | |||
| a5d726b678 | |||
| 93223caa56 | |||
| d0572ce138 | |||
| b0d1fc589d | |||
| 5a4f124199 | |||
| c3ceb7d0c7 | |||
| 16d1208344 | |||
| 1cebc84c2d | |||
| 1f79660add | |||
| 37bdf60a1c | |||
| cc7acfe485 | |||
| 7bfb2a6185 | |||
| ca56ca5216 | |||
| b49ba062d5 | |||
| 09699847a2 | |||
| 545d0c2784 | |||
| b795af3b1b | |||
| a40cc6bc0e | |||
| 4d3d0105b7 | |||
| e90e4f70fc | |||
| d112582857 | |||
| 9f3c4e1e82 | |||
| 71f39366c2 | |||
| bfd5b75677 | |||
| 411b9954b5 | |||
| db70017cb2 | |||
| 86adaedbbd | |||
| 92c7677c6e | |||
| 872f0a50c2 | |||
| 668809bc75 | |||
| 8d8f45c843 | |||
| 086b3e9107 | |||
| 43fe26f324 | |||
| 865c1e5113 | |||
| 19eed8d581 | |||
| ad1850b110 | |||
| 585de259a2 | |||
| 953664dce0 | |||
| 1dd959848c | |||
| 047753c836 | |||
| b63626a94d | |||
| ae080becd6 | |||
| aa86304794 | |||
| 75963ea973 | |||
| 5d0ceb1a5d | |||
| 6f0c0536a4 | |||
| 6b2cd358e8 | |||
| e09e1be76d | |||
| 759ac63139 | |||
| 333f469efb | |||
| c4ec925aa5 | |||
| 5c8cfed483 | |||
| 962510b7e3 | |||
| e9c268a584 | |||
| fa91fcf744 | |||
| c6073511a2 | |||
| b5e1b83bb4 | |||
| 1c11595504 | |||
| cd0e921158 | |||
| f8c4c061f8 | |||
| 81eacd79fd | |||
| 1ef680c75d | |||
| 820fa08313 | |||
| 9e79e491e2 | |||
| 1b83b8fcda | |||
| ff4aba2ffa | |||
| feaac1ab7e | |||
| bbc6ada21e | |||
| 3927dbfcdf | |||
| b52bbb4cca | |||
| 52819501eb | |||
| 7b18287374 | |||
| 33e3bf2888 | |||
| 72ab506d7b | |||
| 5dc49a71d6 | |||
| 1adb484d3f | |||
| aadf7e92a6 | |||
| df42b9ed7b | |||
| 632ae79c8b | |||
| b5fed82883 | |||
| 145e20db75 | |||
| ee2e97e932 | |||
| b561236325 | |||
| e685179b7e | |||
| ac8feafdcc | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 87882a8561 | |||
| 77ef7e0df1 | |||
| 12100dd901 | |||
| 20c12d006e | |||
| 3aab02d2eb | |||
| 553aac65e6 | |||
| be49a3ce18 | |||
| 774a340400 | |||
| 04c9d41895 | |||
| dfb7b870af | |||
| 37821e1108 | |||
| cd711a25d5 | |||
| eee60c9e11 | |||
| c40c8dbc24 | |||
| 2d93c9c373 | |||
| 3d5bba93f8 | |||
| e5ab3af05b | |||
| 79bc7a8257 | |||
| 40b88b62ef | |||
| e3361bc321 | |||
| fc48c1bd1e | |||
| da726beffc | |||
| 4ef12b9aac | |||
| 4d550cecfd | |||
| 34695843b3 | |||
| d17be9decd | |||
| b828ddcec7 | |||
| f859bb179d | |||
| 18bbca748f | |||
| f142aad8ad | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| e63a886f98 | |||
| 8dd650fd05 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| 668ca9f5de | |||
| f659a45311 | |||
| 12c1da29ee | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 |
+3
-10
@@ -4,16 +4,9 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "5.x"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
- "*"
|
||||
@@ -2,7 +2,7 @@ name: Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -28,12 +28,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
@@ -44,15 +44,17 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.3-composer-
|
||||
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.4-composer-
|
||||
|
||||
- name: Install Dependencies
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||
|
||||
- name: Profanity Check
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [4.x]
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
@@ -24,21 +24,18 @@ jobs:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||
symfony: ['7.4', '8.0']
|
||||
php: ['8.3', '8.4', '8.5']
|
||||
symfony: ['8.0']
|
||||
php: ['8.4', '8.5']
|
||||
dependency_version: [prefer-stable]
|
||||
exclude:
|
||||
- php: '8.3'
|
||||
symfony: '8.0'
|
||||
|
||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
@@ -51,7 +48,7 @@ jobs:
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
@@ -66,6 +63,8 @@ jobs:
|
||||
|
||||
- name: Install PHP dependencies
|
||||
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 }}"
|
||||
|
||||
- name: Unit Tests
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
.idea/*
|
||||
.idea/codeStyleSettings.xml
|
||||
.temp/*
|
||||
composer.lock
|
||||
/composer.lock
|
||||
/vendor/
|
||||
coverage.xml
|
||||
.phpunit.result.cache
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# CLAUDE.md
|
||||
|
||||
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
|
||||
|
||||
## Ask before testing
|
||||
|
||||
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
|
||||
|
||||
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
|
||||
|
||||
Once the user confirms:
|
||||
|
||||
```bash
|
||||
composer test:unit # fast, excludes the visual group
|
||||
composer test:integration # visual and snapshot tests
|
||||
composer test # everything CI runs, in CI's order
|
||||
composer update:snapshots # only when a test was added or removed
|
||||
```
|
||||
|
||||
## TIA scenario tests
|
||||
|
||||
`tests/Features/Tia/*` scaffold a throwaway git project, run a real `pest` subprocess against it, and diff the TIA graph it wrote. They exist because TIA's contract is about what a run *writes* — replay, branch keys, and the COMPLETE / RESULTS-ONLY / HARD-SUPPRESSED tiers are invisible to ordinary assertions, and every case used to be measured by hand against a playground app.
|
||||
|
||||
Add one whenever a change touches branch resolution, replay, filtered mode, or the write tiers. How:
|
||||
|
||||
- `Project::make('master')` scaffolds; `seed('master')` writes a graph and sentinels every cached result (`time=9.999`, `assertions=42`) so any rewrite shows up.
|
||||
- `$project->pest('--tia', …)` runs it; `$project->delta()` compares against that snapshot. `writtenCount()` is the discriminator — `0` means "replayed", not "wrote the same values". `mutateGraph()` bends one entry; overlays in `tests/Fixtures/Tia/overlays/<name>/` supply a different `tests/Pest.php`.
|
||||
- Keep expectations driver-independent: a cold recording run needs pcov/xdebug and behaves differently without one. Seed a graph instead of recording one.
|
||||
|
||||
Run them by file (a directory argument finds nothing) or by `--filter`:
|
||||
|
||||
```bash
|
||||
php bin/pest tests/Features/Tia/PartialRunWriteTier.php
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
|
||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
||||
<p align="center">
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="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>
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
------
|
||||
|
||||
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
|
||||
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
|
||||
|
||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
||||
|
||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||
- Follow the creator Nuno Maduro:
|
||||
@@ -29,26 +29,13 @@
|
||||
|
||||
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
|
||||
|
||||
|
||||
### Platinum Sponsors
|
||||
|
||||
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
||||
|
||||
### Gold Sponsors
|
||||
|
||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||
|
||||
### 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)
|
||||
- **[PhpStorm](https://jb.gg/nuno)**
|
||||
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||
|
||||
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:
|
||||
|
||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
||||
> **For Pest v4 you should use the `4.x` branch instead.**
|
||||
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
|
||||
- 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)
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
|
||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||
- Run the tests locally using: `composer test`
|
||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||
|
||||
+154
-19
@@ -25,26 +25,72 @@ const PAGE_DIR_CANDIDATES = [
|
||||
|
||||
async function loadRolldown() {
|
||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||
const path = projectRequire.resolve('rolldown')
|
||||
let path = null
|
||||
|
||||
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||
|
||||
if (path === null) {
|
||||
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||
// dependency of the vite package rather than a top-level install.
|
||||
try {
|
||||
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||
path = viteRequire.resolve('rolldown')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (path === null) return null
|
||||
|
||||
return await import(pathToFileURL(path).href)
|
||||
}
|
||||
|
||||
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')
|
||||
const stripped = raw
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
||||
return JSON.parse(stripped)
|
||||
return JSON.parse(stripJsonComments(raw))
|
||||
}
|
||||
|
||||
async function loadAliasFromTsconfig() {
|
||||
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||
const p = join(PROJECT_ROOT, name)
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
let cfg
|
||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||
for (const [key, targets] of Object.entries(paths)) {
|
||||
if (!key.endsWith('/*')) continue
|
||||
@@ -58,6 +104,74 @@ async function loadAliasFromTsconfig() {
|
||||
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 []
|
||||
|
||||
@@ -121,8 +235,16 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const { rolldown } = await loadRolldown()
|
||||
const alias = await loadAliasFromTsconfig()
|
||||
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()
|
||||
@@ -167,7 +289,7 @@ async function main() {
|
||||
cwd: PROJECT_ROOT,
|
||||
resolve: {
|
||||
alias,
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||
},
|
||||
transform: { jsx: 'preserve' },
|
||||
treeshake: false,
|
||||
@@ -192,6 +314,11 @@ async function main() {
|
||||
|
||||
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) {
|
||||
@@ -200,13 +327,17 @@ async function main() {
|
||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||
acc.add(rel)
|
||||
}
|
||||
if (stack.has(dep)) continue
|
||||
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)
|
||||
transitiveCache.set(id, acc)
|
||||
if (complete) transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
@@ -230,10 +361,14 @@ async function main() {
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
try {
|
||||
void pathToFileURL
|
||||
await main()
|
||||
} catch (err) {
|
||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||
process.exit(1)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-16
@@ -17,21 +17,20 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"composer/xdebug-handler": "^3.0.5",
|
||||
"nunomaduro/collision": "^8.9.4",
|
||||
"php": "^8.4",
|
||||
"brianium/paratest": "^7.23.1",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^4.0.0",
|
||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||
"phpunit/phpunit": "^12.5.28",
|
||||
"symfony/process": "^7.4.13|^8.1.0"
|
||||
"pestphp/pest-plugin": "^5.0.0",
|
||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||
"phpunit/phpunit": "^13.2.6",
|
||||
"symfony/process": "^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.28",
|
||||
"phpunit/phpunit": ">13.2.6",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@@ -49,6 +48,7 @@
|
||||
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
||||
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
|
||||
"Tests\\": "tests/PHPUnit/"
|
||||
},
|
||||
"classmap": [
|
||||
@@ -59,17 +59,19 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"mrpunyapal/peststan": "^0.2.10",
|
||||
"pestphp/pest-dev-tools": "^4.1.0",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||
"psy/psysh": "^0.12.23"
|
||||
"pestphp/pest-dev-tools": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||
"psy/psysh": "^0.12.24"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"preferred-install": "dist",
|
||||
"process-timeout": 0,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
@@ -93,6 +95,7 @@
|
||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --group=integration -v",
|
||||
"test:tia": "php bin/pest --group=tia -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||
"test": [
|
||||
"@test:lint",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2011 Brian Scaturro
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ParaTest\WrapperRunner;
|
||||
|
||||
use PHPUnit\TextUI\Output\Printer;
|
||||
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||
* instead of being treated as unexpected output.
|
||||
*/
|
||||
final readonly class ProgressPrinterOutput implements Printer
|
||||
{
|
||||
public function __construct(
|
||||
private Printer $progressPrinter,
|
||||
private Printer $outputPrinter,
|
||||
) {}
|
||||
|
||||
public function print(string $buffer): void
|
||||
{
|
||||
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||
if (
|
||||
$buffer === "\n"
|
||||
|| preg_match('/^ +$/', $buffer) === 1
|
||||
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($buffer) {
|
||||
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
||||
default => $this->outputPrinter->print($buffer),
|
||||
};
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->progressPrinter->flush();
|
||||
$this->outputPrinter->flush();
|
||||
}
|
||||
}
|
||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
||||
(static function () use ($suiteClassFile) {
|
||||
try {
|
||||
include_once $suiteClassFile;
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
} catch (Throwable $e) {
|
||||
Panic::with($e);
|
||||
}
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
})();
|
||||
|
||||
$loadedClasses = array_values(
|
||||
|
||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||
* "T" and swallows the "S" that follows it.
|
||||
*/
|
||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if (str_contains($event->message(), '__TODO__')) {
|
||||
if ($event->message() === '__TODO__') {
|
||||
$this->printTodoItem();
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
$this->printer()->testSkipped();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
private function printTodoItem(): void
|
||||
{
|
||||
$mirror = new ReflectionClass($this->printer());
|
||||
$printerMirror = $mirror->getMethod('printProgress');
|
||||
$printerMirror->invoke($this->printer(), 'T');
|
||||
$printProgress = $mirror->getMethod('printProgress');
|
||||
$printProgress->invoke($this->printer(), 'T');
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
- phpstan-pest-extension.neon
|
||||
- vendor/mrpunyapal/peststan/extension.neon
|
||||
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 7
|
||||
|
||||
+2
-1
@@ -18,8 +18,9 @@
|
||||
<directory suffix=".php">./tests</directory>
|
||||
<directory suffix=".php">./tests-external</directory>
|
||||
<exclude>./tests/.snapshots</exclude>
|
||||
<exclude>./tests/.tests</exclude>
|
||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||
<exclude>./tests/Fixtures/Suites</exclude>
|
||||
<exclude>./tests/Fixtures/Tia</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
|
||||
+39
@@ -2,22 +2,61 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||
use Pest\Rector\Set\PestSetList;
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
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\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\ReturnNeverTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSets([
|
||||
PestSetList::CODING_STYLE,
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
__DIR__.'/tests/Fixtures/Arch',
|
||||
__DIR__.'/tests/Fixtures/Suites',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
NarrowObjectReturnTypeRector::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(
|
||||
deadCode: true,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# Pest Testing 5
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||
|
||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||
|
||||
@boostsnippet("Basic Pest Test Example", "php")
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
@boostsnippet("Pest Response Assertion", "php")
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
@boostsnippet("Pest Dataset Example", "php")
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
@endboostsnippet
|
||||
|
||||
## Pest 5 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Tia (Test Impact Analysis)
|
||||
|
||||
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||
|
||||
@boostsnippet("Tia Example", "shell")
|
||||
./vendor/bin/pest --parallel --tia
|
||||
@endboostsnippet
|
||||
|
||||
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||
|
||||
### New Validation Expectations
|
||||
|
||||
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||
|
||||
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||
expect('nuno@pestphp.com')->toBeEmail();
|
||||
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||
expect('192.168.1.1')->toBeIpAddress();
|
||||
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||
expect('example.com')->toBeHostname();
|
||||
expect('example.co.uk')->toBeDomain();
|
||||
expect('Zm9vYmFy')->toBeBase64();
|
||||
expect('deadbeef')->toBeHexadecimal();
|
||||
@endboostsnippet
|
||||
|
||||
### Time-Balanced Sharding
|
||||
|
||||
Distribute tests across CI shards by execution time rather than count:
|
||||
|
||||
@boostsnippet("Pest Sharding Example", "shell")
|
||||
./vendor/bin/pest --update-shards
|
||||
./vendor/bin/pest --shard=1/4
|
||||
@endboostsnippet
|
||||
|
||||
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
@boostsnippet("Pest Browser Test Example", "php")
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
@endboostsnippet
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
@boostsnippet("Architecture Test Example", "php")
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
@endboostsnippet
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div>
|
||||
<span class="text-gray mr-1">- </span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
||||
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const array FILES = [
|
||||
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||
'Runner/Filter/NameFilterIterator.php',
|
||||
'Runner/ResultCache/DefaultResultCache.php',
|
||||
'Runner/TestSuiteLoader.php',
|
||||
|
||||
@@ -35,6 +35,12 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnNoticeTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPhpNoticeTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnDeprecationTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPhpDeprecationTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnWarningTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPhpWarningTriggered::class,
|
||||
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
||||
];
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
|
||||
use PHPUnit\Framework\IncompleteTest;
|
||||
use PHPUnit\Framework\SkippedTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||
use ReflectionException;
|
||||
use ReflectionFunction;
|
||||
use ReflectionParameter;
|
||||
@@ -278,12 +280,14 @@ trait Testable
|
||||
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
||||
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||
$replay = ReplayType::fromStatus($status);
|
||||
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
$this->__replay = $replay;
|
||||
|
||||
match ($replay) {
|
||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||
@@ -317,7 +321,7 @@ trait Testable
|
||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||
{
|
||||
$this->__replay = $replay;
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
@@ -431,10 +435,15 @@ trait Testable
|
||||
unset($this->{$property});
|
||||
}
|
||||
|
||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
||||
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||
|
||||
if ($hasOutputExpectation) {
|
||||
if ($outputBuffer->hasExpectation()) {
|
||||
ob_clean();
|
||||
|
||||
Closure::bind(function (): void {
|
||||
$this->expectedString = null;
|
||||
$this->expectedRegularExpression = null;
|
||||
}, $outputBuffer, OutputBuffer::class)();
|
||||
}
|
||||
|
||||
$this->setUp();
|
||||
@@ -450,7 +459,9 @@ trait Testable
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||
|
||||
$expectedClass = $read('expectedException');
|
||||
|
||||
@@ -458,13 +469,19 @@ trait Testable
|
||||
return $e instanceof $expectedClass;
|
||||
}
|
||||
|
||||
$expectedMessage = $read('expectedExceptionMessage');
|
||||
$expectedMessage = $read('expectedMessage');
|
||||
|
||||
if ($expectedMessage !== null) {
|
||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedExceptionCode');
|
||||
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||
|
||||
if ($expectedMessageRegex !== null) {
|
||||
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedCode');
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
return $e->getCode() === $expectedCode;
|
||||
|
||||
+11
-3
@@ -33,7 +33,7 @@ final readonly class Configuration
|
||||
*/
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
||||
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,14 @@ final readonly class Configuration
|
||||
return new Browser\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -59,12 +59,15 @@ final class Project
|
||||
/**
|
||||
* Sets the test project to GitLab.
|
||||
*/
|
||||
public function gitlab(string $project): self
|
||||
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
||||
{
|
||||
$this->issues = "https://gitlab.com/{$project}/issues/%s";
|
||||
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
|
||||
// Simple way to ensure only the host is used
|
||||
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
||||
|
||||
$this->assignees = 'https://gitlab.com/%s';
|
||||
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
||||
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
|
||||
|
||||
$this->assignees = "https://{$hostname}/%s";
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $name, string $scope)
|
||||
{
|
||||
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
||||
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
||||
*/
|
||||
public function __construct(string $name)
|
||||
{
|
||||
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class DatasetProviderError extends RuntimeException
|
||||
{
|
||||
public function __construct(Throwable $previous)
|
||||
{
|
||||
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
||||
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance for the given class name.
|
||||
*/
|
||||
public static function fromClassName(string $filename, string $className): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||
$filename,
|
||||
$className,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Exception instance for the given namespace.
|
||||
*/
|
||||
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||
$filename,
|
||||
$namespace,
|
||||
$part,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class MissingDependency extends InvalidArgumentException implements Except
|
||||
*/
|
||||
public function __construct(string $feature, string $dependency)
|
||||
{
|
||||
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency));
|
||||
parent::__construct(sprintf('The feature [%s] requires [%s].', $feature, $dependency));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
||||
*/
|
||||
public function __construct(string $fileName, string $description)
|
||||
{
|
||||
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
||||
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
||||
*/
|
||||
public function __construct(string $testCaseClass)
|
||||
{
|
||||
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
||||
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $fileName)
|
||||
{
|
||||
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
||||
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresCommit extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Tia mode requires a repository with at least one commit, so the baseline it records can be anchored to a revision.',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires at least one commit.',
|
||||
'',
|
||||
' A baseline is anchored to the revision it was recorded at, and this repository',
|
||||
' has none yet, so there is nothing to record against and nothing to compare a',
|
||||
' later run to.',
|
||||
'',
|
||||
' Commit once, then run again:',
|
||||
'',
|
||||
' <fg=yellow>git add . && git commit -m "Initial commit"</>',
|
||||
'',
|
||||
' Runs without <fg=yellow>--tia</> are unaffected.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresDefaultBranch extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Tia mode could not determine the default branch every other branch falls back to reading.',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode could not determine the default branch.',
|
||||
'',
|
||||
' It is the branch whose baseline every other branch falls back to reading, and',
|
||||
' nothing in this checkout names it: the repository has a remote, but no',
|
||||
' <fg=yellow>origin/HEAD</>, and no CI provider stated it either. Guessing would re-run the',
|
||||
' whole suite on every new branch while reporting it as a cache hit.',
|
||||
'',
|
||||
' Name the branch in <fg=yellow>tests/Pest.php</>:',
|
||||
'',
|
||||
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
||||
'',
|
||||
' Or let git answer, once per clone:',
|
||||
'',
|
||||
' <fg=yellow>git remote set-head origin --auto</>',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresRemote extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Tia mode requires a repository with a remote, so the default branch every other branch falls back to reading can be resolved.',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires a repository with a remote.',
|
||||
'',
|
||||
' Without one there is no way to tell which branch is the default — the branch',
|
||||
' whose baseline every other branch falls back to reading — so the first run on',
|
||||
' each new branch would silently re-run the whole suite.',
|
||||
'',
|
||||
' Add a remote, or name the branch yourself in <fg=yellow>tests/Pest.php</>:',
|
||||
'',
|
||||
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $subdirectoryPrefix)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||
$this->subdirectoryPrefix,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
|
||||
'',
|
||||
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
|
||||
'',
|
||||
' Give the project its own git repository to use Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+15
-17
@@ -112,7 +112,7 @@ final class Expectation
|
||||
if (function_exists('dump')) {
|
||||
dump($this->value, ...$arguments);
|
||||
} else {
|
||||
var_dump($this->value);
|
||||
var_dump($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -120,16 +120,22 @@ final class Expectation
|
||||
|
||||
/**
|
||||
* Dump the expectation value and end the script.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function dd(mixed ...$arguments): void
|
||||
public function dd(mixed ...$arguments): never
|
||||
{
|
||||
if (function_exists('dd')) {
|
||||
dd($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
var_dump($this->value);
|
||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||
ob_start();
|
||||
var_dump($this->value, ...$arguments);
|
||||
$output = ob_get_clean();
|
||||
|
||||
throw new ExpectationFailedException($output);
|
||||
}
|
||||
|
||||
var_dump($this->value, ...$arguments);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
@@ -238,7 +244,7 @@ final class Expectation
|
||||
if ($callbacks[$index] instanceof Closure) {
|
||||
$callbacks[$index](new self($value), new self($key));
|
||||
} else {
|
||||
(new self($value))->toEqual($callbacks[$index]);
|
||||
new self($value)->toEqual($callbacks[$index]);
|
||||
}
|
||||
|
||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||
@@ -915,15 +921,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||
"to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -1138,8 +1136,8 @@ final class Expectation
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||
&& $object->reflectionClass->isEnum()
|
||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
'to be '.$backingType.' backed enum',
|
||||
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.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
||||
/**
|
||||
* Accesses properties in the value or in the expectation.
|
||||
*
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
{
|
||||
|
||||
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
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;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||
"not to use traits '".implode("', '", $traits)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
|
||||
|
||||
return Targeted::make(
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||
"not to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$exporter = Exporter::default();
|
||||
|
||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
||||
|
||||
throw new ExpectationFailedException(sprintf(
|
||||
'Expecting %s not %s %s.',
|
||||
$toString($this->original->value),
|
||||
$exporter->shortenedExport($this->original->value),
|
||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| ! $object->reflectionClass->isEnum()
|
||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
'not to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
||||
use Pest\Contracts\HasPrintableTestCaseName;
|
||||
use Pest\Evaluators\Attributes;
|
||||
use Pest\Exceptions\DatasetMissing;
|
||||
use Pest\Exceptions\InvalidTestClassName;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Exceptions\TestAlreadyExist;
|
||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||
@@ -94,7 +95,8 @@ final class TestCaseFactory
|
||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||
}
|
||||
|
||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
||||
$realpath = (string) realpath($filename);
|
||||
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||
|
||||
@@ -137,6 +139,16 @@ final class TestCaseFactory
|
||||
|
||||
if (trim($className) === '') {
|
||||
$className = 'InvalidTestName'.Str::random();
|
||||
} elseif (! Str::isValidClassName($className)) {
|
||||
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
||||
}
|
||||
|
||||
if ($this->namespace === null) {
|
||||
foreach ($partsFQN as $partFQN) {
|
||||
if (! Str::isValidIdentifier($partFQN)) {
|
||||
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->attributes = [
|
||||
@@ -149,6 +161,8 @@ final class TestCaseFactory
|
||||
|
||||
$attributesCode = Attributes::code($this->attributes);
|
||||
|
||||
$filenameLiteral = var_export($realpath, true);
|
||||
|
||||
$methodsCode = implode('', array_map(
|
||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||
$methods
|
||||
@@ -158,6 +172,7 @@ final class TestCaseFactory
|
||||
$classCode = <<<PHP
|
||||
namespace $namespace;
|
||||
|
||||
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||
use Pest\TestSuite as __PestTestSuite;
|
||||
|
||||
@@ -166,7 +181,7 @@ final class TestCaseFactory
|
||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||
$traitsCode
|
||||
|
||||
public static \$__filename = '$filename';
|
||||
public static \$__filename = $filenameLiteral;
|
||||
|
||||
$methodsCode
|
||||
}
|
||||
@@ -197,7 +212,7 @@ final class TestCaseFactory
|
||||
|
||||
if (
|
||||
$method->closure instanceof \Closure &&
|
||||
(new \ReflectionFunction($method->closure))->isStatic()
|
||||
new \ReflectionFunction($method->closure)->isStatic()
|
||||
) {
|
||||
|
||||
throw new TestClosureMustNotBeStatic($method);
|
||||
|
||||
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
|
||||
$attributesCode
|
||||
public function $methodName(...\$arguments)
|
||||
{
|
||||
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||
}
|
||||
|
||||
return \$this->__runTest(
|
||||
\$this->__test,
|
||||
...\$arguments,
|
||||
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
|
||||
|
||||
public static function $dataProviderName()
|
||||
{
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
try {
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
} catch (\Throwable \$throwable) {
|
||||
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||
}
|
||||
}
|
||||
|
||||
EOF;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Laravel\Pao\Execution;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -28,6 +29,10 @@ final class KernelDump
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
if (class_exists(Execution::class) && Execution::running()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ob_start(function (string $message): string {
|
||||
$this->buffer .= $message;
|
||||
|
||||
|
||||
+21
-22
@@ -15,11 +15,6 @@ use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
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\TestSuiteForTestMethodWithDataProvider;
|
||||
use PHPUnit\Framework\Exception as FrameworkException;
|
||||
@@ -254,25 +249,29 @@ final readonly class Converter
|
||||
...$result->testMarkedIncompleteEvents(),
|
||||
];
|
||||
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored
|
||||
|| $event instanceof AfterLastTestMethodErrored
|
||||
|| $event instanceof BeforeFirstTestMethodFailed
|
||||
|| $event instanceof AfterLastTestMethodFailed) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
$notPassedTests = [];
|
||||
|
||||
return $this->getTestCaseLocation($event->test());
|
||||
},
|
||||
$events
|
||||
)
|
||||
)
|
||||
);
|
||||
foreach ($events as $event) {
|
||||
if ($event instanceof AfterLastTestMethodErrored) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
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();
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||
}
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun()
|
||||
- count(array_unique($notPassedTests))
|
||||
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||
|
||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,12 @@ final class TeamCityLogger
|
||||
$telemetry->memoryUsageSinceStart(),
|
||||
$telemetry->durationSincePrevious(),
|
||||
$telemetry->memoryUsageSincePrevious(),
|
||||
$telemetry->userCpuTimeSinceStart(),
|
||||
$telemetry->systemCpuTimeSinceStart(),
|
||||
$telemetry->totalCpuTimeSinceStart(),
|
||||
$telemetry->userCpuTimeSincePrevious(),
|
||||
$telemetry->systemCpuTimeSincePrevious(),
|
||||
$telemetry->totalCpuTimeSincePrevious(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+133
-1
@@ -936,7 +936,7 @@ final class Expectation
|
||||
|
||||
if ($exception instanceof Closure) {
|
||||
$callback = $exception;
|
||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
||||
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||
|
||||
if (count($parameters) !== 1) {
|
||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||
@@ -1142,6 +1142,22 @@ final class Expectation
|
||||
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
|
||||
*
|
||||
@@ -1155,6 +1171,22 @@ final class Expectation
|
||||
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
|
||||
*
|
||||
@@ -1187,4 +1219,104 @@ final class Expectation
|
||||
|
||||
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
|
||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||
*
|
||||
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
|
||||
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
|
||||
|
||||
$varType = $scope->getType($expr->var);
|
||||
|
||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
||||
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,7 @@ final class UsesCall
|
||||
$this->targets = [$filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
||||
*/
|
||||
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||
public function compact(): self
|
||||
{
|
||||
DefaultPrinter::compact(true);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '4.7.1';
|
||||
return '5.0.4';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
||||
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||
return $this->pushArgument('--cache-result', $arguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ trait HandleArguments
|
||||
*/
|
||||
public function popArgument(string $argument, array $arguments): array
|
||||
{
|
||||
$arguments = array_flip($arguments);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
|
||||
unset($arguments[$argument]);
|
||||
while ($key !== false) {
|
||||
unset($arguments[$key]);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
}
|
||||
|
||||
return array_values(array_flip($arguments));
|
||||
return array_values($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class Coverage implements AddsOutput, HandlesArguments
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
private const string COVERAGE_OPTION = 'coverage';
|
||||
|
||||
private const string MIN_OPTION = 'min';
|
||||
@@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
return false;
|
||||
}))];
|
||||
|
||||
$originals = array_flip($originals);
|
||||
foreach ($arguments as $argument) {
|
||||
unset($originals[$argument]);
|
||||
$originals = $this->popArgument($argument, $originals);
|
||||
}
|
||||
$originals = array_flip($originals);
|
||||
|
||||
$inputs = [];
|
||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||
|
||||
@@ -114,6 +114,41 @@ final readonly class Help implements HandlesArguments
|
||||
],
|
||||
];
|
||||
|
||||
$content['Tia'] = [
|
||||
[
|
||||
'arg' => '--tia',
|
||||
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
|
||||
],
|
||||
[
|
||||
'arg' => '--no-tia',
|
||||
'desc' => 'Disable test impact analysis for this run',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --fresh',
|
||||
'desc' => 'Discard the recorded dependency graph and record it again',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --filtered',
|
||||
'desc' => 'Narrow the run to the affected test files only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --locally',
|
||||
'desc' => 'Enable test impact analysis on local machines only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --baselined',
|
||||
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --refetch',
|
||||
'desc' => 'Force a fresh fetch of the shared dependency graph',
|
||||
],
|
||||
[
|
||||
'arg' => '--baseline',
|
||||
'desc' => 'Output to standard output the test impact analysis storage directory',
|
||||
],
|
||||
];
|
||||
|
||||
$content['Execution'] = [...[
|
||||
[
|
||||
'arg' => '--parallel',
|
||||
|
||||
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
|
||||
{
|
||||
$arguments = new ArgvInput;
|
||||
|
||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,10 +120,6 @@ final class ResultPrinter
|
||||
|
||||
$unexpectedOutput = $this->tail($outputFile);
|
||||
if ($unexpectedOutput !== '') {
|
||||
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->write($unexpectedOutput);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ namespace Pest\Plugins\Parallel\Paratest;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||
use ParaTest\Coverage\CoverageMerger;
|
||||
use ParaTest\JUnit\LogMerger;
|
||||
use ParaTest\JUnit\Writer;
|
||||
use ParaTest\Options;
|
||||
use ParaTest\RunnerInterface;
|
||||
use ParaTest\WrapperRunner\MissingResultsException;
|
||||
use ParaTest\WrapperRunner\SuiteLoader;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Result;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event\Facade as EventFacade;
|
||||
@@ -25,11 +26,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||
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 SplFileInfo;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function array_filter;
|
||||
use function array_merge;
|
||||
use function array_merge_recursive;
|
||||
use function array_shift;
|
||||
@@ -37,6 +44,8 @@ use function assert;
|
||||
use function count;
|
||||
use function dirname;
|
||||
use function file_get_contents;
|
||||
use function filesize;
|
||||
use function is_file;
|
||||
use function max;
|
||||
use function realpath;
|
||||
use function str_starts_with;
|
||||
@@ -49,14 +58,14 @@ use function usleep;
|
||||
*/
|
||||
final class WrapperRunner implements RunnerInterface
|
||||
{
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
/**
|
||||
* The merged test result from the parallel run.
|
||||
*/
|
||||
public static ?TestResult $result = null;
|
||||
|
||||
/**
|
||||
* The time to sleep between cycles.
|
||||
*/
|
||||
private const int CYCLE_SLEEP = 10000;
|
||||
|
||||
/**
|
||||
@@ -83,6 +92,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
/** @var array<int,int> */
|
||||
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> */
|
||||
private array $unexpectedOutputFiles = [];
|
||||
|
||||
@@ -135,6 +156,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
/** @var array<int, non-empty-string> $parameters */
|
||||
$parameters = $this->handleLaravelHerd($parameters);
|
||||
$parameters = $this->handleTia($parameters);
|
||||
|
||||
$parameters[] = $wrapper;
|
||||
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||
@@ -182,6 +204,19 @@ final class WrapperRunner implements RunnerInterface
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, non-empty-string> $parameters
|
||||
* @return array<int, non-empty-string>
|
||||
*/
|
||||
private function handleTia(array $parameters): array
|
||||
{
|
||||
if (! Tia::recordsEdgesInWorkers()) {
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
|
||||
}
|
||||
|
||||
private function startWorkers(): void
|
||||
{
|
||||
for ($token = 1; $token <= $this->options->processes; $token++) {
|
||||
@@ -212,7 +247,7 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
if (
|
||||
$this->exitcode > 0
|
||||
&& $this->options->configuration->stopOnFailure()
|
||||
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
||||
) {
|
||||
$this->pending = [];
|
||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||
@@ -227,6 +262,18 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
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->printer->printFeedback(
|
||||
$worker->progressFile,
|
||||
@@ -274,10 +321,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$worker->start();
|
||||
$this->batches[$token] = 0;
|
||||
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->statusFiles[] = $worker->statusFile;
|
||||
$this->progressFiles[] = $worker->progressFile;
|
||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||
$this->testResultFiles[] = $worker->testResultFile;
|
||||
|
||||
if (isset($worker->resultCacheFile)) {
|
||||
$this->resultCacheFiles[] = $worker->resultCacheFile;
|
||||
}
|
||||
|
||||
if (isset($worker->junitFile)) {
|
||||
$this->junitFiles[] = $worker->junitFile;
|
||||
}
|
||||
@@ -310,6 +362,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
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) {
|
||||
if (! $testResultFile->isFile()) {
|
||||
continue;
|
||||
@@ -344,6 +410,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||
// @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()),
|
||||
// @phpstan-ignore-next-line
|
||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||
@@ -379,8 +459,15 @@ final class WrapperRunner implements RunnerInterface
|
||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||
array_values(array_filter(
|
||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
|
||||
)),
|
||||
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
|
||||
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
|
||||
$testResultSum->errors(),
|
||||
$testResultSum->deprecations(),
|
||||
$testResultSum->notices(),
|
||||
@@ -416,8 +503,11 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||
|
||||
$this->clearFiles($this->statusFiles);
|
||||
$this->clearFiles($this->progressFiles);
|
||||
$this->clearFiles($this->unexpectedOutputFiles);
|
||||
$this->clearFiles($this->testResultFiles);
|
||||
$this->clearFiles($this->resultCacheFiles);
|
||||
$this->clearFiles($this->coverageFiles);
|
||||
$this->clearFiles($this->junitFiles);
|
||||
$this->clearFiles($this->teamcityFiles);
|
||||
@@ -432,6 +522,20 @@ final class WrapperRunner implements RunnerInterface
|
||||
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->init(
|
||||
$this->options->configuration,
|
||||
@@ -447,10 +551,33 @@ final class WrapperRunner implements RunnerInterface
|
||||
|
||||
return;
|
||||
}
|
||||
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
||||
foreach ($this->coverageFiles as $coverageFile) {
|
||||
$coverageMerger->addCoverageFromFile($coverageFile);
|
||||
$coverageFiles = [];
|
||||
foreach ($this->coverageFiles as $fileInfo) {
|
||||
$realPath = $fileInfo->getRealPath();
|
||||
if ($realPath !== false && $realPath !== '') {
|
||||
$coverageFiles[] = $realPath;
|
||||
}
|
||||
}
|
||||
$serializedCoverage = (new Merger)->merge($coverageFiles);
|
||||
|
||||
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
|
||||
$serializedCoverage['codeCoverage'],
|
||||
$serializedCoverage['testResults'],
|
||||
$serializedCoverage['basePath'],
|
||||
);
|
||||
$codeCoverage = $coverageManager->codeCoverage();
|
||||
$codeCoverage->excludeUncoveredFiles();
|
||||
|
||||
$mergedData = $serializedCoverage['codeCoverage'];
|
||||
$basePath = $serializedCoverage['basePath'];
|
||||
if ($basePath !== '') {
|
||||
foreach ($mergedData->coveredFiles() as $relativePath) {
|
||||
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||
}
|
||||
}
|
||||
$codeCoverage->setData($mergedData);
|
||||
$codeCoverage->setTests($serializedCoverage['testResults']);
|
||||
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
|
||||
|
||||
$coverageManager->generateReports(
|
||||
$this->printer->printer,
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||
use ParaTest\Options;
|
||||
use PHPUnit\Event\Telemetry\CpuTime;
|
||||
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\Telemetry\Info;
|
||||
@@ -147,11 +148,20 @@ final class CompactPrinter
|
||||
MemoryUsage::fromBytes(0),
|
||||
MemoryUsage::fromBytes(0),
|
||||
$garbageCollectorStatus,
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||
),
|
||||
$telemetryDuration,
|
||||
MemoryUsage::fromBytes(0),
|
||||
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 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);
|
||||
|
||||
+105
-12
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
self::$timeBalanced = true;
|
||||
self::$shardsOutdated = $newTests !== [];
|
||||
} else {
|
||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
||||
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||
}
|
||||
|
||||
self::$shard = [
|
||||
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
||||
$filter = $this->buildFilterArgument($testsToRun);
|
||||
|
||||
$this->ensureFilterLengthIsSafe($filter);
|
||||
|
||||
return [...$arguments, '--filter', $filter];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function allTests(array $arguments): array
|
||||
{
|
||||
$output = (new Process([
|
||||
'php',
|
||||
...$this->removeParallelArguments($arguments),
|
||||
'--list-tests',
|
||||
]))->setTimeout(120)->mustRun()->getOutput();
|
||||
$command = $this->buildListTestsCommand(
|
||||
$arguments,
|
||||
TestSuite::getInstance()->testPath,
|
||||
);
|
||||
|
||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
||||
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
return $this->parseListTestsOutput($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function removeParallelArguments(array $arguments): array
|
||||
{
|
||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
||||
return array_values(array_filter(
|
||||
$arguments,
|
||||
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
|
||||
&& ! str_starts_with($argument, '--processes'),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the subprocess command used to enumerate tests via `--list-tests`.
|
||||
*
|
||||
* @param list<string> $arguments
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildListTestsCommand(array $arguments, string $testPath): array
|
||||
{
|
||||
$filtered = $this->removeParallelArguments($arguments);
|
||||
|
||||
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `--list-tests` output into a unique list of test class FQCNs.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function parseListTestsOutput(string $output): array
|
||||
{
|
||||
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the filter argument for the given tests to run.
|
||||
*
|
||||
* @param array<int, string> $testsToRun
|
||||
*/
|
||||
private function buildFilterArgument(mixed $testsToRun): string
|
||||
private function buildFilterArgument(array $testsToRun): string
|
||||
{
|
||||
return addslashes(implode('|', $testsToRun));
|
||||
if ($testsToRun === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $tree */
|
||||
$tree = [];
|
||||
foreach ($testsToRun as $class) {
|
||||
$parts = explode('\\', $class);
|
||||
$current = &$tree;
|
||||
foreach ($parts as $part) {
|
||||
if (! isset($current[$part])) {
|
||||
$current[$part] = [];
|
||||
}
|
||||
$current = &$current[$part];
|
||||
}
|
||||
}
|
||||
|
||||
$buildRegex = function (array $tree) use (&$buildRegex): string {
|
||||
$parts = [];
|
||||
foreach ($tree as $key => $sub) {
|
||||
$subRegex = $buildRegex($sub);
|
||||
if ($subRegex === '') {
|
||||
$parts[] = preg_quote($key, '/');
|
||||
} else {
|
||||
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
|
||||
}
|
||||
}
|
||||
|
||||
return implode('|', $parts);
|
||||
};
|
||||
|
||||
return $buildRegex($tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the filter length is safe for the current environment.
|
||||
*
|
||||
* @throws InvalidOption
|
||||
*/
|
||||
private function ensureFilterLengthIsSafe(string $filter): void
|
||||
{
|
||||
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
|
||||
|
||||
if (strlen($filter) > $maxLength) {
|
||||
throw new InvalidOption(sprintf(
|
||||
'The generated filter for this shard is too long (%d characters). '.
|
||||
'This can cause issues with some environments (limit is %d characters). '.
|
||||
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
|
||||
strlen($filter),
|
||||
$maxLength
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+562
-63
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,19 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Pest\Support\Git;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class ChangedFiles
|
||||
{
|
||||
public function __construct(private string $projectRoot) {}
|
||||
private Git $git;
|
||||
|
||||
public function __construct(private string $projectRoot)
|
||||
{
|
||||
$this->git = new Git($projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $files project-relative paths.
|
||||
@@ -155,15 +160,7 @@ final readonly class ChangedFiles
|
||||
|
||||
private function contentAtSha(string $sha, string $path): ?string
|
||||
{
|
||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
||||
$process->setTimeout(5.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $process->getOutput();
|
||||
return $this->git->show($sha, $path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,21 +173,17 @@ final readonly class ChangedFiles
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
$process = new Process(
|
||||
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
|
||||
$this->projectRoot,
|
||||
$result = $this->git->result(
|
||||
['check-ignore', '--no-index', '-z', '--stdin'],
|
||||
implode("\x00", array_keys($candidates)),
|
||||
);
|
||||
$process->setTimeout(5.0);
|
||||
$process->setInput(implode("\x00", array_keys($candidates)));
|
||||
$process->run();
|
||||
|
||||
$exitCode = $process->getExitCode();
|
||||
|
||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||
// `check-ignore` exits 1 when nothing matched — that is not a failure.
|
||||
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
$output = $result['output'];
|
||||
|
||||
if ($output === '') {
|
||||
return $candidates;
|
||||
@@ -207,27 +200,109 @@ final readonly class ChangedFiles
|
||||
|
||||
public function currentBranch(): ?string
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
$output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
if ($output === null) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$branch = trim($process->getOutput());
|
||||
$branch = trim($output);
|
||||
|
||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||
}
|
||||
|
||||
public function defaultBranch(): ?string
|
||||
{
|
||||
$head = $this->git->output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
||||
|
||||
if ($head !== null) {
|
||||
$branch = preg_replace('#^origin/#', '', $head);
|
||||
|
||||
if (is_string($branch) && $branch !== '') {
|
||||
return $branch;
|
||||
}
|
||||
}
|
||||
|
||||
$configured = $this->git->output(['config', '--get', 'init.defaultBranch']);
|
||||
|
||||
if ($configured === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$exists = $this->git->hasRef('refs/heads/'.$configured)
|
||||
|| $this->git->hasRef('refs/remotes/origin/'.$configured);
|
||||
|
||||
return $exists ? $configured : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null
|
||||
*/
|
||||
public function branchNames(): ?array
|
||||
{
|
||||
$output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
|
||||
|
||||
if ($output === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($this->splitLines($output) as $ref) {
|
||||
if (str_starts_with($ref, 'refs/heads/')) {
|
||||
$names[substr($ref, strlen('refs/heads/'))] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($ref, 'refs/remotes/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tail = substr($ref, strlen('refs/remotes/'));
|
||||
$slash = strpos($tail, '/');
|
||||
|
||||
if ($slash === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$branch = substr($tail, $slash + 1);
|
||||
|
||||
if ($branch !== '' && $branch !== 'HEAD') {
|
||||
$names[$branch] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($names);
|
||||
}
|
||||
|
||||
public function hasRemote(): bool
|
||||
{
|
||||
return $this->git->hasRemote();
|
||||
}
|
||||
|
||||
public function isRepository(): bool
|
||||
{
|
||||
return $this->git->isRepository();
|
||||
}
|
||||
|
||||
public function hasCommits(): bool
|
||||
{
|
||||
return $this->git->hasCommits();
|
||||
}
|
||||
|
||||
/**
|
||||
* Working-tree scans get a longer leash than metadata queries — on a large
|
||||
* repository with a cold cache, `status` and `diff` are not instant.
|
||||
*/
|
||||
private function scan(): Git
|
||||
{
|
||||
return $this->git->withTimeout(60.0);
|
||||
}
|
||||
|
||||
private function shaIsReachable(string $sha): bool
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
|
||||
return $process->getExitCode() === 0;
|
||||
return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,17 +310,13 @@ final readonly class ChangedFiles
|
||||
*/
|
||||
private function diffSinceSha(string $sha): array
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'diff', '--name-only', $sha.'..HEAD'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
$output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
if ($output === null) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
return $this->splitLines($process->getOutput());
|
||||
return $this->splitLines($output);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,18 +324,12 @@ final readonly class ChangedFiles
|
||||
*/
|
||||
private function workingTreeChanges(): array
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->run();
|
||||
$output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
if ($output === null) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$output = $process->getOutput();
|
||||
|
||||
if ($output === '') {
|
||||
return [];
|
||||
}
|
||||
@@ -302,14 +367,13 @@ final readonly class ChangedFiles
|
||||
|
||||
public function currentSha(): ?string
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
|
||||
$process->run();
|
||||
$output = $this->git->raw(['rev-parse', 'HEAD']);
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
if ($output === null) {
|
||||
throw new MissingDependency('Tia mode', 'git');
|
||||
}
|
||||
|
||||
$sha = trim($process->getOutput());
|
||||
$sha = trim($output);
|
||||
|
||||
return $sha === '' ? null : $sha;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Ci;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CiDefaultBranch
|
||||
{
|
||||
/**
|
||||
* @var array<int, class-string<Ci>>
|
||||
*/
|
||||
private const array CIS = [
|
||||
Cis\GitLab::class,
|
||||
Cis\GitHub::class,
|
||||
];
|
||||
|
||||
public static function detect(): ?string
|
||||
{
|
||||
foreach (self::CIS as $class) {
|
||||
$branch = (new $class)->defaultBranch();
|
||||
|
||||
if ($branch !== null) {
|
||||
return $branch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Cis\Concerns;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
trait ReadsEnvironment
|
||||
{
|
||||
private function environment(string $name): ?string
|
||||
{
|
||||
$value = getenv($name);
|
||||
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Cis;
|
||||
|
||||
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
|
||||
use Pest\Plugins\Tia\Contracts\Ci;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class GitHub implements Ci
|
||||
{
|
||||
use ReadsEnvironment;
|
||||
|
||||
public function defaultBranch(): ?string
|
||||
{
|
||||
$path = $this->environment('GITHUB_EVENT_PATH');
|
||||
|
||||
if ($path === null || ! is_file($path) || ! is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = @file_get_contents($path);
|
||||
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($contents, true);
|
||||
|
||||
if (! is_array($payload) || ! is_array($payload['repository'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$branch = $payload['repository']['default_branch'] ?? null;
|
||||
|
||||
return is_string($branch) && $branch !== '' ? $branch : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Cis;
|
||||
|
||||
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
|
||||
use Pest\Plugins\Tia\Contracts\Ci;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class GitLab implements Ci
|
||||
{
|
||||
use ReadsEnvironment;
|
||||
|
||||
public function defaultBranch(): ?string
|
||||
{
|
||||
return $this->environment('CI_DEFAULT_BRANCH');
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,18 @@ final class Configuration
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function defaultBranch(string $branch): self
|
||||
{
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->setDefaultBranch($branch);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $patterns glob → project-relative test dir
|
||||
* @return $this
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Ci
|
||||
{
|
||||
/**
|
||||
* The default branch advertised by this CI, or `null` when the run
|
||||
* does not happen on it — or when it exposes no such information.
|
||||
*/
|
||||
public function defaultBranch(): ?string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Lockfile
|
||||
{
|
||||
public function applies(string $filename): bool;
|
||||
|
||||
public function fingerprint(string $contents): ?string;
|
||||
}
|
||||
@@ -8,6 +8,9 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -161,7 +164,60 @@ final class CoverageMerger
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
|
||||
if ($value instanceof CodeCoverage) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
|
||||
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
|
||||
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
|
||||
// be rebuilt into one before it can be merged.
|
||||
return self::coverageFromSerializedData($reportPath);
|
||||
}
|
||||
|
||||
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
|
||||
{
|
||||
if ($reportPath === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$serialized = new Unserializer()->unserialize($reportPath);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $serialized['codeCoverage'];
|
||||
$basePath = $serialized['basePath'];
|
||||
|
||||
if ($basePath !== '') {
|
||||
foreach ($data->coveredFiles() as $relativePath) {
|
||||
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
$coverage = self::emptyCoverage();
|
||||
|
||||
if (! $coverage instanceof CodeCoverage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$coverage->setData($data);
|
||||
$coverage->setTests($serialized['testResults']);
|
||||
|
||||
return $coverage;
|
||||
}
|
||||
|
||||
private static function emptyCoverage(): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
$filter = new Filter;
|
||||
|
||||
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||
|
||||
@@ -29,7 +29,9 @@ enum ReplayType
|
||||
$status->isRisky() => self::Risky,
|
||||
$status->isSkipped() => self::Skipped,
|
||||
$status->isIncomplete() => self::Incomplete,
|
||||
default => self::Failure,
|
||||
$status->isNotice(), $status->isDeprecation(), $status->isWarning() => self::Pass,
|
||||
$status->isFailure(), $status->isError() => self::Failure,
|
||||
default => self::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ final class FileState implements State
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (str_ends_with($path, '.tmp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
@@ -11,7 +12,14 @@ use Symfony\Component\Finder\Finder;
|
||||
*/
|
||||
final readonly class Fingerprint
|
||||
{
|
||||
private const int SCHEMA_VERSION = 17;
|
||||
private const int SCHEMA_VERSION = 18;
|
||||
|
||||
/**
|
||||
* @var array<int, class-string<Lockfile>>
|
||||
*/
|
||||
private const array LOCKFILES = [
|
||||
Lockfiles\PackageLock::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
@@ -205,7 +213,11 @@ final readonly class Fingerprint
|
||||
$parts = [];
|
||||
|
||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||
$hash = self::trackedHash($projectRoot, $name);
|
||||
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||
|
||||
if ($hash !== null) {
|
||||
$parts[] = $name.':'.$hash;
|
||||
@@ -215,6 +227,37 @@ final readonly class Fingerprint
|
||||
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)) {
|
||||
|
||||
+401
-31
@@ -43,11 +43,14 @@ final class Graph
|
||||
* @var array<string, array{
|
||||
* sha: ?string,
|
||||
* tree: array<string, string>,
|
||||
* complete?: bool,
|
||||
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
* }>
|
||||
*/
|
||||
private array $baselines = [];
|
||||
|
||||
private string $fallbackBranch = 'main';
|
||||
|
||||
private readonly string $projectRoot;
|
||||
|
||||
/** @var array<string, true>|null */
|
||||
@@ -116,6 +119,24 @@ final class Graph
|
||||
return array_keys($affectedSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $testFiles Project-relative paths.
|
||||
* @return list<string>
|
||||
*/
|
||||
public function testFilesOnDisk(array $testFiles): array
|
||||
{
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
$onDisk = [];
|
||||
|
||||
foreach ($testFiles as $testFile) {
|
||||
if (is_file($root.$testFile)) {
|
||||
$onDisk[] = $testFile;
|
||||
}
|
||||
}
|
||||
|
||||
return $onDisk;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $changedFiles
|
||||
* @return array{0: list<string>, 1: list<string>}
|
||||
@@ -149,6 +170,13 @@ final class Graph
|
||||
*/
|
||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||
{
|
||||
// With no recorded table usage at all, table intersection can never
|
||||
// select anything — route every migration change through the
|
||||
// watch-pattern fallback instead of silently skipping tests.
|
||||
if ($this->testTables === []) {
|
||||
return $migrationPaths;
|
||||
}
|
||||
|
||||
$changedTables = [];
|
||||
$unparseable = [];
|
||||
|
||||
@@ -446,13 +474,14 @@ final class Graph
|
||||
|
||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||
|
||||
// Only a walk that actually selected tests counts as handled — a
|
||||
// component whose usage the static walk missed must still reach
|
||||
// the watch-pattern fallback instead of being silently swallowed.
|
||||
if ($bladeAffected !== []) {
|
||||
foreach ($bladeAffected as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
$staticallyHandled[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
$staticallyHandled[$rel] = true;
|
||||
}
|
||||
}
|
||||
@@ -568,7 +597,12 @@ final class Graph
|
||||
return $this->fingerprint;
|
||||
}
|
||||
|
||||
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
|
||||
public function setFallbackBranch(string $branch): void
|
||||
{
|
||||
$this->fallbackBranch = $branch;
|
||||
}
|
||||
|
||||
public function recordedAtSha(string $branch, ?string $fallbackBranch = null): ?string
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
@@ -603,7 +637,7 @@ final class Graph
|
||||
$this->baselines[$branch]['results'][$testId] = $entry;
|
||||
}
|
||||
|
||||
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
|
||||
public function getAssertions(string $branch, string $testId, ?string $fallbackBranch = null): ?int
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
@@ -614,7 +648,18 @@ final class Graph
|
||||
return $baseline['results'][$testId]['assertions'];
|
||||
}
|
||||
|
||||
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
|
||||
public function getTime(string $branch, string $testId, ?string $fallbackBranch = null): ?float
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
if (! isset($baseline['results'][$testId]['time'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $baseline['results'][$testId]['time'];
|
||||
}
|
||||
|
||||
public function getResult(string $branch, string $testId, ?string $fallbackBranch = null): ?TestStatus
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
@@ -634,14 +679,14 @@ final class Graph
|
||||
6 => TestStatus::warning($r['message']),
|
||||
7 => TestStatus::failure($r['message']),
|
||||
8 => TestStatus::error($r['message']),
|
||||
default => TestStatus::unknown(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array
|
||||
public function testFilesToRerun(string $branch, ?string $fallbackBranch = null): array
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
$files = [];
|
||||
@@ -661,7 +706,7 @@ final class Graph
|
||||
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
if ($rel !== null && is_file($this->projectRoot.'/'.$rel)) {
|
||||
$files[$rel] = true;
|
||||
}
|
||||
}
|
||||
@@ -669,7 +714,7 @@ final class Graph
|
||||
return array_keys($files);
|
||||
}
|
||||
|
||||
public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
|
||||
public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
@@ -680,7 +725,11 @@ final class Graph
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if ($file === null || $file === '' || $this->relative($file) === null) {
|
||||
if ($file === null || $file === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->relative($file) === null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -690,12 +739,24 @@ final class Graph
|
||||
|
||||
private function shouldRerun(int $status): bool
|
||||
{
|
||||
$testStatus = TestStatus::from($status);
|
||||
return $this->shouldRerunStatus(TestStatus::from($status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cached result with this status must be re-executed rather
|
||||
* than replayed, honouring the configured failOn* / displayDetailsOn*
|
||||
* policies.
|
||||
*/
|
||||
public function shouldRerunStatus(TestStatus $testStatus): bool
|
||||
{
|
||||
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($testStatus->isUnknown()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$configuration = Registry::get();
|
||||
|
||||
if ($testStatus->isRisky()) {
|
||||
@@ -763,25 +824,70 @@ final class Graph
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
|
||||
public function lastRunTree(string $branch, ?string $fallbackBranch = null): array
|
||||
{
|
||||
return $this->baselineFor($branch, $fallbackBranch)['tree'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
|
||||
* @return array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
|
||||
*/
|
||||
private function baselineFor(string $branch, string $fallbackBranch): array
|
||||
private function baselineFor(string $branch, ?string $fallbackBranch): array
|
||||
{
|
||||
if (isset($this->baselines[$branch])) {
|
||||
return $this->baselines[$branch];
|
||||
$fallbackBranch ??= $this->fallbackBranch;
|
||||
|
||||
$fallback = $branch !== $fallbackBranch ? ($this->baselines[$fallbackBranch] ?? null) : null;
|
||||
$own = $this->baselines[$branch] ?? null;
|
||||
|
||||
if ($own === null) {
|
||||
return $fallback ?? ['sha' => null, 'tree' => [], 'results' => []];
|
||||
}
|
||||
|
||||
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) {
|
||||
return $this->baselines[$fallbackBranch];
|
||||
if ($fallback === null) {
|
||||
return $own;
|
||||
}
|
||||
|
||||
return ['sha' => null, 'tree' => [], 'results' => []];
|
||||
$under = ($own['complete'] ?? false) === true
|
||||
? $this->withoutFilesCoveredBy($fallback['results'], $own['results'])
|
||||
: $fallback['results'];
|
||||
|
||||
return [
|
||||
'sha' => $own['sha'] ?? $fallback['sha'],
|
||||
'tree' => $own['tree'] !== [] ? $own['tree'] : $fallback['tree'],
|
||||
'results' => array_replace($under, $own['results']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $results
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $authoritative
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
*/
|
||||
private function withoutFilesCoveredBy(array $results, array $authoritative): array
|
||||
{
|
||||
$covered = [];
|
||||
|
||||
foreach ($authoritative as $entry) {
|
||||
$file = $entry['file'] ?? null;
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$covered[$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($covered === []) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
foreach ($results as $testId => $entry) {
|
||||
$file = $entry['file'] ?? null;
|
||||
|
||||
if (is_string($file) && isset($covered[$file])) {
|
||||
unset($results[$testId]);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function ensureBaseline(string $branch): void
|
||||
@@ -793,8 +899,9 @@ final class Graph
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $testToFiles
|
||||
* @param bool $keepExisting Leave already-recorded edge sets alone.
|
||||
*/
|
||||
public function replaceEdges(array $testToFiles): void
|
||||
public function replaceEdges(array $testToFiles, bool $keepExisting = false): void
|
||||
{
|
||||
foreach ($testToFiles as $testFile => $sources) {
|
||||
$testRel = $this->relative($testFile);
|
||||
@@ -803,6 +910,10 @@ final class Graph
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($keepExisting && ($this->edges[$testRel] ?? []) !== []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->edges[$testRel] = [];
|
||||
|
||||
foreach ($sources as $source) {
|
||||
@@ -813,6 +924,38 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark test files that executed under a recorded coverage session as "known",
|
||||
* seeding an empty edge set for any that produced zero project-source edges.
|
||||
*
|
||||
* Without this, a test that covers no application source (e.g. a pure unit
|
||||
* test asserting on language primitives) never becomes an edge key, so
|
||||
* {@see self::knowsTest()} reports it as unknown and it re-runs on every TIA
|
||||
* run. Recording it with an empty edge set lets it be replayed/skipped while
|
||||
* unchanged; it is still re-run whenever its own file changes, via
|
||||
* {@see self::applyTestFileChanges()}.
|
||||
*
|
||||
* Must only be called from the recording path, where coverage was actually
|
||||
* collected — otherwise a missing edge set could mean "coverage was off",
|
||||
* not "genuinely covered nothing".
|
||||
*
|
||||
* @param array<int, string> $testFiles Absolute or project-relative test file paths.
|
||||
*/
|
||||
public function markKnownTestFiles(array $testFiles): void
|
||||
{
|
||||
foreach ($testFiles as $testFile) {
|
||||
$rel = $this->relative($testFile);
|
||||
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($this->edges[$rel])) {
|
||||
$this->edges[$rel] = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<int, string>> $testToTables
|
||||
*/
|
||||
@@ -1217,7 +1360,21 @@ final class Graph
|
||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||
$name = str_replace('/', '.', $tail);
|
||||
|
||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
||||
if ($name === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [$name, str_replace('_', '-', $name)];
|
||||
|
||||
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||
$base = substr($name, 0, -strlen('.index'));
|
||||
|
||||
$names[] = $base;
|
||||
$names[] = str_replace('_', '-', $base);
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
@@ -1289,13 +1446,7 @@ final class Graph
|
||||
/** @param array<string, array<int, string>> $edges */
|
||||
private function anyTestUses(array $edges, string $component): bool
|
||||
{
|
||||
foreach ($edges as $components) {
|
||||
if (in_array($component, $components, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
|
||||
}
|
||||
|
||||
public function pruneMissingTests(): void
|
||||
@@ -1321,6 +1472,56 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
public function markBaselineComplete(string $branch): void
|
||||
{
|
||||
if (isset($this->baselines[$branch])) {
|
||||
$this->baselines[$branch]['complete'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function pruneResultsForMissingFiles(string $branch): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch]['results'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
if (! is_string($file)) {
|
||||
continue;
|
||||
}
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = $this->relative($file);
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
if (is_file($root.$rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->baselines[$branch]['results'][$testId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $keep
|
||||
*/
|
||||
public function pruneMissingBranches(array $keep): void
|
||||
{
|
||||
$survivors = array_fill_keys($keep, true);
|
||||
|
||||
foreach (array_keys($this->baselines) as $branch) {
|
||||
if (! isset($survivors[$branch])) {
|
||||
unset($this->baselines[$branch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune baseline result entries whose test files were just executed but whose
|
||||
* test IDs are no longer present (e.g. the test method was removed or renamed).
|
||||
@@ -1366,6 +1567,28 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function branchesIn(string $json): array
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (! is_array($data) || ! is_array($data['baselines'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$branches = [];
|
||||
|
||||
foreach (array_keys($data['baselines']) as $branch) {
|
||||
if (is_string($branch) && $branch !== '') {
|
||||
$branches[] = $branch;
|
||||
}
|
||||
}
|
||||
|
||||
return $branches;
|
||||
}
|
||||
|
||||
public static function decode(string $json, string $projectRoot): ?self
|
||||
{
|
||||
$data = json_decode($json, true);
|
||||
@@ -1376,10 +1599,10 @@ final class Graph
|
||||
|
||||
$graph = new self($projectRoot);
|
||||
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
|
||||
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
|
||||
$graph->files = self::decodeFiles($data['files'] ?? null);
|
||||
$graph->fileIds = array_flip($graph->files);
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
$graph->edges = self::decodeEdges($data['edges'] ?? null);
|
||||
$graph->baselines = self::decodeBaselines($data['baselines'] ?? null);
|
||||
|
||||
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
||||
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
||||
@@ -1388,6 +1611,153 @@ final class Graph
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function decodeFiles(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
foreach ($section as $path) {
|
||||
if (is_string($path) && $path !== '') {
|
||||
$files[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, int>>
|
||||
*/
|
||||
private static function decodeEdges(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$edges = [];
|
||||
|
||||
foreach ($section as $key => $ids) {
|
||||
$testFile = (string) $key;
|
||||
|
||||
if ($testFile === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$clean[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$edges[$testFile] = $clean;
|
||||
}
|
||||
|
||||
return $edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}>
|
||||
*/
|
||||
private static function decodeBaselines(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baselines = [];
|
||||
|
||||
foreach ($section as $key => $baseline) {
|
||||
$branch = (string) $key;
|
||||
|
||||
if ($branch === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($baseline)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sha = $baseline['sha'] ?? null;
|
||||
$tree = [];
|
||||
|
||||
if (is_array($baseline['tree'] ?? null)) {
|
||||
foreach ($baseline['tree'] as $path => $hash) {
|
||||
if (is_string($path) && is_string($hash)) {
|
||||
$tree[$path] = $hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$baselines[$branch] = [
|
||||
'sha' => is_string($sha) ? $sha : null,
|
||||
'tree' => $tree,
|
||||
'results' => self::decodeResults($baseline['results'] ?? null),
|
||||
];
|
||||
|
||||
if (($baseline['complete'] ?? null) === true) {
|
||||
$baselines[$branch]['complete'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
*/
|
||||
private static function decodeResults(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($section as $key => $entry) {
|
||||
$testId = (string) $key;
|
||||
|
||||
if ($testId === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_int($entry['status'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$time = $entry['time'] ?? null;
|
||||
|
||||
$result = [
|
||||
'status' => $entry['status'],
|
||||
'message' => is_string($entry['message'] ?? null) ? $entry['message'] : '',
|
||||
'time' => is_int($time) || is_float($time) ? (float) $time : 0.0,
|
||||
];
|
||||
|
||||
if (is_int($entry['assertions'] ?? null)) {
|
||||
$result['assertions'] = $entry['assertions'];
|
||||
}
|
||||
|
||||
if (is_string($entry['file'] ?? null) && $entry['file'] !== '') {
|
||||
$result['file'] = $entry['file'];
|
||||
}
|
||||
|
||||
$results[$testId] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
|
||||
@@ -163,7 +163,9 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
||||
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||
|
||||
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -386,12 +388,6 @@ final class JsModuleGraph
|
||||
|
||||
private static function hasViteConfig(string $projectRoot): bool
|
||||
{
|
||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Lockfiles;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class PackageLock implements Lockfile
|
||||
{
|
||||
public function applies(string $filename): bool
|
||||
{
|
||||
return $filename === 'package-lock.json';
|
||||
}
|
||||
|
||||
public function fingerprint(string $contents): ?string
|
||||
{
|
||||
$data = json_decode($contents, true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$packages = $data['packages'];
|
||||
|
||||
$platformPaths = [];
|
||||
|
||||
foreach ($packages as $path => $meta) {
|
||||
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
|
||||
$platformPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach ($packages as $path => $meta) {
|
||||
if (! is_string($path)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($meta)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isPlatformSpecific($meta)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
|
||||
continue;
|
||||
}
|
||||
$version = $meta['version'] ?? null;
|
||||
|
||||
$entries[$path] = is_string($version) ? $version : '';
|
||||
}
|
||||
|
||||
ksort($entries);
|
||||
|
||||
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $encoded === false ? null : hash('xxh128', $encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function isPlatformSpecific(array $meta): bool
|
||||
{
|
||||
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $platformPaths
|
||||
*/
|
||||
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
|
||||
{
|
||||
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ final class Recorder
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $captureCoverage = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
|
||||
private bool $driverAvailable = false;
|
||||
@@ -43,6 +45,17 @@ final class Recorder
|
||||
private ?SourceScope $sourceScope = null;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -55,7 +68,7 @@ final class Recorder
|
||||
public function driverAvailable(): bool
|
||||
{
|
||||
if (! $this->driverChecked) {
|
||||
if (function_exists('pcov\\start')) {
|
||||
if (function_exists('pcov\\start') && filter_var((string) ini_get('pcov.enabled'), FILTER_VALIDATE_BOOL)) {
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||
@@ -75,7 +88,11 @@ final class Recorder
|
||||
|
||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable()) {
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +112,10 @@ final class Recorder
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
\pcov\start();
|
||||
@@ -107,7 +128,13 @@ final class Recorder
|
||||
|
||||
public function endTest(): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
||||
if (! $this->active || $this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||
$this->currentTestFile = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,7 +159,15 @@ final class Recorder
|
||||
$data = \xdebug_get_code_coverage();
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$coveredFiles = array_keys($data);
|
||||
$scope = $this->sourceScope();
|
||||
|
||||
foreach (array_keys($data) as $file) {
|
||||
if (! $scope->contains($file)) {
|
||||
unset($data[$file]);
|
||||
}
|
||||
}
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
}
|
||||
|
||||
foreach ($coveredFiles as $sourceFile) {
|
||||
@@ -325,7 +360,9 @@ final class Recorder
|
||||
}
|
||||
|
||||
$lineKeys = array_keys($lines);
|
||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
$reportsUnexecutedLines = count($covered) < count($lines);
|
||||
|
||||
if ($reportsUnexecutedLines && $lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -351,5 +388,6 @@ final class Recorder
|
||||
$this->classUsesDatabaseCache = [];
|
||||
$this->sourceScope = null;
|
||||
$this->active = false;
|
||||
$this->captureCoverage = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ final class ResultCollector
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $triggered = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?string $currentTestFile = null;
|
||||
@@ -35,9 +38,30 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($this->triggered[$this->currentTestId])) {
|
||||
$this->refreshTime();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::success());
|
||||
}
|
||||
|
||||
public function testTriggeredNotice(string $message): void
|
||||
{
|
||||
$this->recordIssue(TestStatus::notice($message));
|
||||
}
|
||||
|
||||
public function testTriggeredDeprecation(string $message): void
|
||||
{
|
||||
$this->recordIssue(TestStatus::deprecation($message));
|
||||
}
|
||||
|
||||
public function testTriggeredWarning(string $message): void
|
||||
{
|
||||
$this->recordIssue(TestStatus::warning($message));
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
@@ -91,6 +115,11 @@ final class ResultCollector
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
public function hasUnfinishedTest(): bool
|
||||
{
|
||||
return $this->currentTestId !== null;
|
||||
}
|
||||
|
||||
public function recordAssertions(string $testId, int $assertions): void
|
||||
{
|
||||
if (isset($this->results[$testId])) {
|
||||
@@ -111,6 +140,7 @@ final class ResultCollector
|
||||
public function reset(): void
|
||||
{
|
||||
$this->results = [];
|
||||
$this->triggered = [];
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
@@ -123,6 +153,38 @@ final class ResultCollector
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
private function recordIssue(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->results[$this->currentTestId]['status'] ?? null;
|
||||
|
||||
if (is_int($existing) && $existing >= $status->asInt()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->triggered[$this->currentTestId] = true;
|
||||
|
||||
$this->record($status);
|
||||
}
|
||||
|
||||
private function refreshTime(): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
if (! isset($this->results[$this->currentTestId])) {
|
||||
return;
|
||||
}
|
||||
if ($this->startTime === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->results[$this->currentTestId]['time'] = round(microtime(true) - $this->startTime, 3);
|
||||
}
|
||||
|
||||
private function record(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
|
||||
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class TableExtractor
|
||||
{
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||
|
||||
/**
|
||||
* A single (optionally quoted) identifier segment.
|
||||
*/
|
||||
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||
|
||||
/**
|
||||
* @return list<string> Sorted, deduped table names referenced by the
|
||||
@@ -22,22 +27,15 @@ final class TableExtractor
|
||||
return [];
|
||||
}
|
||||
|
||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
||||
|
||||
$matched = false;
|
||||
foreach (self::DML_PREFIXES as $dml) {
|
||||
if (str_starts_with($prefix, $dml)) {
|
||||
$matched = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $matched) {
|
||||
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
||||
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||
|
||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||
return [];
|
||||
@@ -45,14 +43,9 @@ final class TableExtractor
|
||||
|
||||
$tables = [];
|
||||
|
||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
||||
$name = $matches[1][$i] !== ''
|
||||
? $matches[1][$i]
|
||||
: ($matches[2][$i] !== ''
|
||||
? $matches[2][$i]
|
||||
: ($matches[3][$i] !== ''
|
||||
? $matches[3][$i]
|
||||
: $matches[4][$i]));
|
||||
foreach ($matches[1] as $qualified) {
|
||||
$name = self::unqualified($qualified);
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -63,7 +56,7 @@ final class TableExtractor
|
||||
$tables[strtolower($name)] = true;
|
||||
}
|
||||
|
||||
$out = array_keys($tables);
|
||||
$out = array_map(strval(...), array_keys($tables));
|
||||
sort($out);
|
||||
|
||||
return $out;
|
||||
@@ -80,51 +73,84 @@ final class TableExtractor
|
||||
|
||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $i => $primary) {
|
||||
$tables[strtolower($primary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||
|
||||
$secondary = $matches[2][$i] ?? '';
|
||||
if ($secondary !== '') {
|
||||
$tables[strtolower($secondary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||
|
||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $primary) {
|
||||
$lower = strtolower($primary);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dmlPatterns = [
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
||||
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
|
||||
$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 ($dmlPatterns as $pattern) {
|
||||
foreach ($sqlPatterns as $pattern) {
|
||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower($name);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$lower = strtolower(self::unqualified($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_keys($tables);
|
||||
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_map(strval(...), 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);
|
||||
|
||||
@@ -45,11 +45,7 @@ final readonly class TestPaths
|
||||
$directories[] = $rel;
|
||||
}
|
||||
|
||||
$suffix = $directory->suffix();
|
||||
|
||||
if ($suffix !== '') {
|
||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||
}
|
||||
$suffixes[] = $directory->suffix();
|
||||
}
|
||||
|
||||
foreach ($suite->files() as $file) {
|
||||
@@ -63,7 +59,7 @@ final readonly class TestPaths
|
||||
|
||||
if ($suffixes === []) {
|
||||
foreach ($configuration->testSuffixes() as $suffix) {
|
||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
||||
$suffixes[] = $suffix;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
@@ -94,15 +90,7 @@ final readonly class TestPaths
|
||||
if (in_array($relativePath, $this->files, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$matchesSuffix = false;
|
||||
foreach ($this->suffixes as $suffix) {
|
||||
if (str_ends_with($relativePath, $suffix)) {
|
||||
$matchesSuffix = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
|
||||
|
||||
if (! $matchesSuffix) {
|
||||
return false;
|
||||
|
||||
@@ -44,6 +44,8 @@ final class WatchPatterns
|
||||
|
||||
private bool $baselined = false;
|
||||
|
||||
private ?string $defaultBranch = null;
|
||||
|
||||
public function useDefaults(string $projectRoot): void
|
||||
{
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
@@ -177,6 +179,16 @@ final class WatchPatterns
|
||||
return $this->baselined;
|
||||
}
|
||||
|
||||
public function setDefaultBranch(string $branch): void
|
||||
{
|
||||
$this->defaultBranch = $branch;
|
||||
}
|
||||
|
||||
public function defaultBranch(): ?string
|
||||
{
|
||||
return $this->defaultBranch;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->patterns = [];
|
||||
@@ -185,6 +197,7 @@ final class WatchPatterns
|
||||
$this->locally = false;
|
||||
$this->filtered = false;
|
||||
$this->baselined = false;
|
||||
$this->defaultBranch = null;
|
||||
}
|
||||
|
||||
private function keyMatches(string $key, string $file): bool
|
||||
@@ -253,35 +266,17 @@ final class WatchPatterns
|
||||
|
||||
private function patternTargetsDotfiles(string $pattern): bool
|
||||
{
|
||||
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
|
||||
if ($segment !== '' && $segment[0] === '.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||
}
|
||||
|
||||
private function touchesVcs(string $file): bool
|
||||
{
|
||||
foreach (explode('/', $file) as $segment) {
|
||||
if (in_array($segment, self::VCS_DIRS, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
|
||||
}
|
||||
|
||||
private function touchesDotfile(string $file): bool
|
||||
{
|
||||
foreach (explode('/', $file) as $segment) {
|
||||
if ($segment !== '' && $segment[0] === '.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||
}
|
||||
|
||||
private function excludeMatches(string $exclude, string $file): bool
|
||||
|
||||
@@ -37,7 +37,7 @@ final class XdebugRestarter implements Restarter
|
||||
return;
|
||||
}
|
||||
|
||||
(new XdebugHandler('pest'))->check();
|
||||
new XdebugHandler('pest')->check();
|
||||
}
|
||||
|
||||
private function xdebugIsCoverageOnly(): bool
|
||||
|
||||
@@ -15,15 +15,20 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
final class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
{
|
||||
/**
|
||||
* Indicates if the TeamCity logger has already been registered.
|
||||
*/
|
||||
private static bool $registered = false;
|
||||
|
||||
/**
|
||||
* Creates a new Configured Subscriber instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private InputInterface $input,
|
||||
private OutputInterface $output,
|
||||
private TestSuite $testSuite,
|
||||
private readonly InputInterface $input,
|
||||
private readonly OutputInterface $output,
|
||||
private readonly TestSuite $testSuite,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,10 +36,16 @@ final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
*/
|
||||
public function notify(Configured $event): void
|
||||
{
|
||||
if (self::$registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->input->hasParameterOption('--teamcity')) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$registered = true;
|
||||
|
||||
$flowId = getenv('FLOW_ID');
|
||||
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->recordAssertions(
|
||||
$test->className().'::'.$test->methodName(),
|
||||
$test->id(),
|
||||
$event->numberOfAssertionsPerformed(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Test\DeprecationTriggered;
|
||||
use PHPUnit\Event\Test\DeprecationTriggeredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultIsRecordedOnDeprecationTriggered implements DeprecationTriggeredSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(DeprecationTriggered $event): void
|
||||
{
|
||||
if ($event->wasSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->collector->testTriggeredDeprecation($event->message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Test\NoticeTriggered;
|
||||
use PHPUnit\Event\Test\NoticeTriggeredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultIsRecordedOnNoticeTriggered implements NoticeTriggeredSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(NoticeTriggered $event): void
|
||||
{
|
||||
if ($event->wasSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->collector->testTriggeredNotice($event->message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Test\PhpDeprecationTriggered;
|
||||
use PHPUnit\Event\Test\PhpDeprecationTriggeredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultIsRecordedOnPhpDeprecationTriggered implements PhpDeprecationTriggeredSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(PhpDeprecationTriggered $event): void
|
||||
{
|
||||
if ($event->wasSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->collector->testTriggeredDeprecation($event->message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Test\PhpNoticeTriggered;
|
||||
use PHPUnit\Event\Test\PhpNoticeTriggeredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultIsRecordedOnPhpNoticeTriggered implements PhpNoticeTriggeredSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(PhpNoticeTriggered $event): void
|
||||
{
|
||||
if ($event->wasSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->collector->testTriggeredNotice($event->message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Test\PhpWarningTriggered;
|
||||
use PHPUnit\Event\Test\PhpWarningTriggeredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultIsRecordedOnPhpWarningTriggered implements PhpWarningTriggeredSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(PhpWarningTriggered $event): void
|
||||
{
|
||||
if ($event->wasSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->collector->testTriggeredWarning($event->message());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use PHPUnit\Event\Test\WarningTriggered;
|
||||
use PHPUnit\Event\Test\WarningTriggeredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class EnsureTiaResultIsRecordedOnWarningTriggered implements WarningTriggeredSubscriber
|
||||
{
|
||||
public function __construct(private ResultCollector $collector) {}
|
||||
|
||||
public function notify(WarningTriggered $event): void
|
||||
{
|
||||
if ($event->wasSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->collector->testTriggeredWarning($event->message());
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparationStartedS
|
||||
$test = $event->test();
|
||||
|
||||
if ($test instanceof TestMethod) {
|
||||
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file());
|
||||
$this->collector->testPrepared($test->id(), $test->file());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||
use SebastianBergmann\CodeCoverage\Node\File;
|
||||
use SebastianBergmann\CodeCoverage\Report\Facade;
|
||||
use SebastianBergmann\Environment\Runtime;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -95,10 +96,18 @@ final class Coverage
|
||||
$codeCoverage = require $reportPath;
|
||||
unlink($reportPath);
|
||||
|
||||
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
|
||||
// @phpstan-ignore-next-line
|
||||
if (is_array($codeCoverage)) {
|
||||
$facade = Facade::fromSerializedData($codeCoverage);
|
||||
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = $codeCoverage->getReport();
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = (fn (): Directory => $this->report)->call($facade);
|
||||
} else {
|
||||
/** @var Directory<File|Directory> $report */
|
||||
$report = $codeCoverage->getReport();
|
||||
}
|
||||
|
||||
$totalCoverage = $report->percentageOfExecutedLines();
|
||||
|
||||
foreach ($report->getIterator() as $file) {
|
||||
if (! $file instanceof File) {
|
||||
|
||||
@@ -86,4 +86,17 @@ final readonly class Exporter
|
||||
|
||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a value into a full single-line string without truncation.
|
||||
*/
|
||||
public function export(mixed $value): string
|
||||
{
|
||||
$map = [
|
||||
'#\\\n\s*#' => '',
|
||||
'# Object \(\.{3}\)#' => '',
|
||||
];
|
||||
|
||||
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Git
|
||||
{
|
||||
private const float TIMEOUT = 5.0;
|
||||
|
||||
public function __construct(
|
||||
private ?string $directory = null,
|
||||
private float $timeout = self::TIMEOUT,
|
||||
) {}
|
||||
|
||||
public function withTimeout(float $timeout): self
|
||||
{
|
||||
return new self($this->directory, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function raw(array $arguments): ?string
|
||||
{
|
||||
$result = $this->result($arguments);
|
||||
|
||||
return $result['exitCode'] === 0 ? $result['output'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function output(array $arguments): ?string
|
||||
{
|
||||
$output = $this->raw($arguments);
|
||||
|
||||
if ($output === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = trim($output);
|
||||
|
||||
return $output === '' ? null : $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function succeeds(array $arguments): bool
|
||||
{
|
||||
return $this->result($arguments)['exitCode'] === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array{exitCode: int, output: string}
|
||||
*/
|
||||
public function result(array $arguments, ?string $input = null): array
|
||||
{
|
||||
$process = new Process(['git', ...$arguments], $this->directory);
|
||||
$process->setTimeout($this->timeout);
|
||||
|
||||
if ($input !== null) {
|
||||
$process->setInput($input);
|
||||
}
|
||||
|
||||
$process->run();
|
||||
|
||||
return [
|
||||
'exitCode' => $process->getExitCode() ?? 1,
|
||||
'output' => $process->getOutput(),
|
||||
];
|
||||
}
|
||||
|
||||
public function isRepository(): bool
|
||||
{
|
||||
return $this->succeeds(['rev-parse', '--git-dir']);
|
||||
}
|
||||
|
||||
public function hasCommits(): bool
|
||||
{
|
||||
return $this->succeeds(['rev-parse', '--verify', '--quiet', 'HEAD']);
|
||||
}
|
||||
|
||||
public function hasRemote(): bool
|
||||
{
|
||||
return $this->output(['remote']) !== null;
|
||||
}
|
||||
|
||||
public function hasRef(string $ref): bool
|
||||
{
|
||||
return $this->output(['rev-parse', '--verify', '--quiet', $ref]) !== null;
|
||||
}
|
||||
|
||||
public function show(string $sha, string $path): ?string
|
||||
{
|
||||
return $this->raw(['show', $sha.':'.$path]);
|
||||
}
|
||||
|
||||
public function subdirectoryPrefix(): ?string
|
||||
{
|
||||
$prefix = $this->output(['rev-parse', '--show-prefix']);
|
||||
|
||||
if ($prefix === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ final class HigherOrderMessage
|
||||
}
|
||||
|
||||
if ($this->hasHigherOrderCallable()) {
|
||||
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
|
||||
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
|
||||
*/
|
||||
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
|
||||
{
|
||||
$this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition);
|
||||
$this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments)->when($condition);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,7 +38,7 @@ final class HigherOrderTapProxy
|
||||
return $this->target->{$property};
|
||||
}
|
||||
|
||||
$className = (new ReflectionClass($this->target))->getName();
|
||||
$className = new ReflectionClass($this->target)->getName();
|
||||
|
||||
if (str_starts_with($className, 'P\\')) {
|
||||
$className = substr($className, 2);
|
||||
@@ -60,7 +60,7 @@ final class HigherOrderTapProxy
|
||||
$filename = Backtrace::file();
|
||||
$line = Backtrace::line();
|
||||
|
||||
return (new HigherOrderMessage($filename, $line, $methodName, $arguments))
|
||||
return new HigherOrderMessage($filename, $line, $methodName, $arguments)
|
||||
->call($this->target);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user