mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
116 Commits
v4.7.4
...
e90e4f70fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 774a340400 | |||
| 3d5bba93f8 | |||
| 79bc7a8257 | |||
| 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 |
@@ -2,7 +2,7 @@ name: Static Analysis
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [4.x]
|
branches: [5.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * *'
|
- cron: '0 9 * * *'
|
||||||
@@ -28,12 +28,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.3
|
php-version: 8.4
|
||||||
tools: composer:v2
|
tools: composer:v2
|
||||||
coverage: none
|
coverage: none
|
||||||
extensions: sockets
|
extensions: sockets
|
||||||
@@ -44,17 +44,17 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
static-php-8.3-${{ matrix.dependency-version }}-composer-
|
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||||
static-php-8.3-composer-
|
static-php-8.4-composer-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
env:
|
env:
|
||||||
COMPOSER_ROOT_VERSION: 4.x-dev
|
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||||
|
|
||||||
- name: Profanity Check
|
- name: Profanity Check
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [4.x]
|
branches: [5.x]
|
||||||
pull_request:
|
pull_request:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * *'
|
- cron: '0 9 * * *'
|
||||||
@@ -24,18 +24,15 @@ jobs:
|
|||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||||
symfony: ['7.4', '8.0']
|
symfony: ['8.0']
|
||||||
php: ['8.3', '8.4', '8.5']
|
php: ['8.4', '8.5']
|
||||||
dependency_version: [prefer-stable]
|
dependency_version: [prefer-stable]
|
||||||
exclude:
|
|
||||||
- php: '8.3'
|
|
||||||
symfony: '8.0'
|
|
||||||
|
|
||||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||||
@@ -51,7 +48,7 @@ jobs:
|
|||||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||||
@@ -67,7 +64,7 @@ jobs:
|
|||||||
- name: Install PHP dependencies
|
- name: Install PHP dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
COMPOSER_ROOT_VERSION: 4.x-dev
|
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||||
|
|
||||||
- name: Unit Tests
|
- name: Unit Tests
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
.idea/*
|
.idea/*
|
||||||
.idea/codeStyleSettings.xml
|
.idea/codeStyleSettings.xml
|
||||||
.temp/*
|
.temp/*
|
||||||
composer.lock
|
/composer.lock
|
||||||
/vendor/
|
/vendor/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# TIA write-tier conformance — plan
|
||||||
|
|
||||||
|
Baseline sweep: **147 cases, 144 PASS / 2 FAIL / 1 SKIP** against `dev-fix/tia-filtered` (`db70017`)
|
||||||
|
on `playground/laravel` with pcov enabled.
|
||||||
|
|
||||||
|
This file records (1) what is left to fix, and (2) the full case matrix with the target
|
||||||
|
outcome for every row, so the sweep can be re-run as a conformance check after the fixes land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — What's left
|
||||||
|
|
||||||
|
### 1. `--parallel` silently under-records `edges` (G12, I5) — **high**
|
||||||
|
|
||||||
|
Any `--tia --parallel` complete run writes `files=4` where sequential writes `files=25`. Only
|
||||||
|
`app/**` and blade views survive; every `config/`, `routes/`, `bootstrap/` edge and every test
|
||||||
|
self-edge is lost. A `routes/web.php` edit that sequential TIA catches, parallel TIA reports as
|
||||||
|
`No affected tests found`.
|
||||||
|
|
||||||
|
Root cause — `src/Restarters/PcovRestarter.php:33`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (! Tia::isEnabledForRun($arguments)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Tia::isEnabledForRun()` (`src/Plugins/Tia.php:275-288`) returns true only for `--tia` in argv or
|
||||||
|
the `PEST_TIA` env flag. A paratest worker's argv carries neither, so workers never re-exec with
|
||||||
|
`-d pcov.directory=<projectRoot>` and record under pcov's empty default scope.
|
||||||
|
|
||||||
|
Proven both directions:
|
||||||
|
- `PEST_PCOV_RESTARTER_RESTARTED=1 pest --tia` (sequential, restart suppressed) reproduces
|
||||||
|
`files=4` with edge sets identical to the parallel graph.
|
||||||
|
- `PEST_TIA=1 pest --parallel` restores the full `files=25` graph. **Working workaround today.**
|
||||||
|
|
||||||
|
Coverage config is irrelevant: widening `phpunit.xml` `<include>` to `app|config|routes|bootstrap|tests`
|
||||||
|
still yields `files=4`.
|
||||||
|
|
||||||
|
Sticky, not just per-run: `pest --tia --filtered --parallel` re-records only the affected test
|
||||||
|
through a worker and strips that entry's edges from an otherwise-healthy sequential graph, so one
|
||||||
|
parallel run degrades a good graph incrementally.
|
||||||
|
|
||||||
|
**Fix direction:** propagate the TIA-enabled decision into workers (e.g. set `PEST_TIA` in the
|
||||||
|
worker env, or have the parent stamp a recording global the restarter also consults) so
|
||||||
|
`PcovRestarter` restarts them. Then assert `edges` equivalence between sequential and parallel.
|
||||||
|
|
||||||
|
### 2. Deleted test file with a cached failure wedges `--filtered` forever (I9) — **high**
|
||||||
|
|
||||||
|
Expected a WARN and a full-suite fallback. Actual: `--filtered` selects the phantom file, runs
|
||||||
|
nothing, exits **0 green**, on every subsequent invocation.
|
||||||
|
|
||||||
|
Root cause — `src/Plugins/Tia/Graph.php:1523-1525`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($real === false) {
|
||||||
|
$real = $path;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`relative()` falls back to the raw path when `realpath()` fails, so a deleted-but-in-project test
|
||||||
|
file is never "unlocated", `Graph::hasUnlocatedTestsToRerun()` never fires, and the WARN branch at
|
||||||
|
`src/Plugins/Tia.php:982-986` is unreachable for the deletion case. `pruneMissingTests()` only runs
|
||||||
|
under `--fresh`, so nothing clears it.
|
||||||
|
|
||||||
|
**Fix direction:** distinguish "path could not be resolved" from "path resolved outside the root" in
|
||||||
|
`relative()`, or have `hasUnlocatedTestsToRerun()` stat the file directly.
|
||||||
|
|
||||||
|
### 3. `--coverage-text` does not disable filtered mode (I8) — **low**
|
||||||
|
|
||||||
|
`coverageReportActive()` (`src/Plugins/Tia.php:1705-1710`) reads only Pest's own `--coverage`, so
|
||||||
|
raw PHPUnit coverage flags (`--coverage-text`, `--coverage-html`, `--coverage-clover`, …) leave
|
||||||
|
filtered mode on. `Tia::COVERAGE_FLAGS` at `:113-115` already enumerates them — that list is not
|
||||||
|
consulted here.
|
||||||
|
|
||||||
|
### 4. `--coverage` narrows edges like parallel does — **medium, needs triage**
|
||||||
|
|
||||||
|
`pest --tia --coverage` prints `fresh graph (recording coverage baseline)` even when a graph
|
||||||
|
exists, and writes `Feature/ExampleTest` with 2 files instead of 16, dropping self-edges. Same
|
||||||
|
observable shape as G12, likely a different mechanism (piggyback collector scoped by
|
||||||
|
`phpunit.xml <include>` rather than the pcov-restarted recorder). Theme: **edges silently narrow to
|
||||||
|
`app/**` whenever recording does not go through the pcov-restarted TIA recorder.** Confirm whether
|
||||||
|
these are one fix or two.
|
||||||
|
|
||||||
|
### 5. Smaller items
|
||||||
|
|
||||||
|
| Item | Where | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| Warning/deprecation recorded as `status=0` | write path | Codes `6` and `4` appear unreachable; risky/skipped/incomplete record faithfully |
|
||||||
|
| Complete non-TIA runs write edge-less results | `src/Plugins/Tia.php:1645`, `:638` | Guard is `! $complete && ! knowsTest()`; complete bypasses it and `markKnownTestFiles` stays false. Inert — replay guards on the same predicate at `:360` — but inflates `n` |
|
||||||
|
| SIGINT never reaches the suite | `PcovRestarter` re-exec | Parent ignores it; the child holds PHPUnit's handler. CI `timeout`/Ctrl-C will not stop a run |
|
||||||
|
| Structural drift never announced sequentially | `src/Plugins/Tia.php:1178-1181` | `enterRecordMode()` prints a bare `Running in TIA mode.`; `renderFreshGraph()` (`fresh graph (composer.lock changed)`) only runs under `--parallel` or coverage piggyback |
|
||||||
|
| Replay clobbers cached `time` | write path | `0.058` → `0.001`; timing data degrades toward zero across runs |
|
||||||
|
| `pest --parallel` without `--tia` writes nothing | G3, G6 | Sequential `pest --filter=…` does record. Parallel CI contributes nothing to the cached-failure replay path |
|
||||||
|
| `--tia --parallel --filter`/`--shard` record nothing | G2, G8 | More conservative than the results-only contract requires |
|
||||||
|
| `--min=50` without `--coverage` is a silent no-op | — | — |
|
||||||
|
| `pest --repeat=2` is not a Pest option | J11 | Case unrunnable as written; drop it or add the option |
|
||||||
|
|
||||||
|
### 6. Test-harness gaps to close before re-running
|
||||||
|
|
||||||
|
- The playground has no `UsesClass`, `->note()`, `->flaky()`, `->issue()`, `->pr()`, `->ticket()` or
|
||||||
|
`->assignee()` annotations, so **C19, C23–C28, C37–C39 matched zero tests** and their
|
||||||
|
graph-invariant assertions proved nothing. Add annotated fixtures to make those rows load-bearing.
|
||||||
|
- Use **sentinel patching** (rewrite every cached entry to `time=9.999 assertions=42`, then see which
|
||||||
|
entries get overwritten) as the discriminator. It is the only way to tell "wrote identical values"
|
||||||
|
from "wrote nothing". A canary test absent from `edges` is unreliable under `--filter` because it
|
||||||
|
never matches the filter and so never runs.
|
||||||
|
- `--tia` on a clean green tree can never cache a failure (unchanged tests replay rather than
|
||||||
|
execute); seeding one requires `--fresh` or an env-driven flaky fixture.
|
||||||
|
- Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Full case matrix (target outcomes)
|
||||||
|
|
||||||
|
Tiers: **COMPLETE** may change everything · **RESULTS-ONLY** (RO) may change only
|
||||||
|
`baselines[<branch>].results` for tests that ran, and must never remove an entry, add a result for a
|
||||||
|
test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||||
|
**HARD-SUPPRESSED** may change nothing.
|
||||||
|
|
||||||
|
Status column: `PASS` = conforming today · `FIX` = must pass once the item above lands ·
|
||||||
|
`SKIP` = case not runnable as written.
|
||||||
|
|
||||||
|
### A — Setup & sanity
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | `composer show pestphp/pest` | version `dev-fix/tia-filtered` | PASS |
|
||||||
|
| A2 | `pest --baseline` | prints an existing dir; exit 0 | PASS |
|
||||||
|
| A3 | delete graph, `pest --tia` | graph.json created; one `edges` key per test file; one result per test | PASS |
|
||||||
|
| A4 | `git status` after reset ritual | clean | PASS |
|
||||||
|
| A5 | `extension_loaded("pcov")` | `true` | PASS |
|
||||||
|
| A6 | delete graph, plain `pest` | no graph created | PASS |
|
||||||
|
| A7 | delete graph, `pest --filter=adds` | no graph created | PASS |
|
||||||
|
| A8 | two consecutive `pest --tia` | second replays everything; `sha` unchanged | PASS |
|
||||||
|
|
||||||
|
### B — COMPLETE runs still write
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 | `pest --tia` (clean) | replays; graph written; `sha` = `git rev-parse HEAD` | PASS |
|
||||||
|
| B2 | `pest --no-tia` | results refreshed; prune applied; `sha`/`tree`/`edges` unchanged | PASS |
|
||||||
|
| B3 | `pest` (plain) | same as B2 | PASS |
|
||||||
|
| B4 | `pest --tia --fresh` | graph purged and rebuilt; `edges` rebuilt; `files` repopulated | PASS |
|
||||||
|
| B5 | `pest --bail`, all green | COMPLETE — graph written, prune applied | PASS |
|
||||||
|
| B6 | edit `Calculator.php`, `pest --tia` | `CalculatorTest` re-runs, others replay; `tree` updated | PASS |
|
||||||
|
| B7 | delete `trio three`, `pest --tia` | its result pruned; `trio one`/`two` remain | PASS |
|
||||||
|
| B8 | delete `GreeterTest.php`, `pest --tia` | result and edges survive (missing-file prune is `--fresh`-only) | PASS |
|
||||||
|
| B9 | B8 then `pest --tia --fresh` | entry gone; `files` drops `Greeter.php` too | PASS |
|
||||||
|
| B10 | `pest --tia` twice | `time` differs; statuses stable | PASS |
|
||||||
|
| B11 | add a test file, `pest --tia` | new `edges` key + new result appear | PASS |
|
||||||
|
| B12 | `pest --tia --coverage` | completes; graph written | PASS |
|
||||||
|
|
||||||
|
### C — Selection narrowing → RESULTS-ONLY
|
||||||
|
|
||||||
|
All rows: RO invariants hold. "Notice" = `TIA does not apply to partial runs — running the selected tests directly.`
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | `pest --filter="adds numbers"` | RO; no notice | PASS |
|
||||||
|
| C2 | `pest --tia --filter="adds numbers"` | RO; notice | PASS |
|
||||||
|
| C3 | `pest --filter="trio one"` | RO; `trio two`/`three` byte-identical | PASS |
|
||||||
|
| C4 | `pest --filter="trio"` | RO; all three update; nothing else does | PASS |
|
||||||
|
| C5 | `pest --exclude-filter="Feature"` | RO; no notice; no prune | PASS |
|
||||||
|
| C6 | `pest --tia --exclude-filter="Feature"` | RO; notice | PASS |
|
||||||
|
| C7 | `pest --group=smoke` | RO; no notice | PASS |
|
||||||
|
| C8 | `pest --tia --group=smoke` | RO; notice | PASS |
|
||||||
|
| C9 | `pest --exclude-group=smoke` | RO; no notice; no prune | PASS |
|
||||||
|
| C10 | `pest --tia --exclude-group=smoke` | RO; notice | PASS |
|
||||||
|
| C11 | `pest tests/Unit` | RO; no notice; Feature entry not pruned | PASS |
|
||||||
|
| C12 | `pest --tia tests/Unit` | RO; notice | PASS |
|
||||||
|
| C13 | `pest tests/Unit/TrioTest.php` | RO; no notice | PASS |
|
||||||
|
| C14 | `pest --tia tests/Unit/TrioTest.php` | RO; notice | PASS |
|
||||||
|
| C15 | `pest --testsuite=Unit` | RO; no notice | PASS |
|
||||||
|
| C16 | `pest --tia --testsuite=Unit` | RO; notice | PASS |
|
||||||
|
| C17 | `pest --tia --exclude-testsuite=Feature` | RO; notice | PASS |
|
||||||
|
| C18 | `pest --tia --covers='App\Services\Calculator'` | RO; notice | PASS |
|
||||||
|
| C19 | `pest --tia --uses='App\Services\Calculator'` | RO; notice | PASS (vacuous — needs fixture) |
|
||||||
|
| C20 | `pest --tia --test-suffix=Test.php` | RO even though all tests run; `edges`/`files`/`n` unchanged | PASS |
|
||||||
|
| C21 | `pest --dirty` with an uncommitted test edit | RO; no notice | PASS |
|
||||||
|
| C22 | `pest --tia --dirty` | RO; notice | PASS |
|
||||||
|
| C23 | `pest --tia --todos` | RO; notice | PASS (vacuous) |
|
||||||
|
| C24 | `pest --tia --notes` | RO; notice | PASS (vacuous) |
|
||||||
|
| C25 | `pest --tia --flaky` | RO; notice | PASS (vacuous) |
|
||||||
|
| C26 | `pest --tia --issue=123` | RO; notice | PASS (vacuous) |
|
||||||
|
| C27 | `pest --tia --pr=1` | RO; notice | PASS (vacuous) |
|
||||||
|
| C28 | `pest --tia --pull-request=1` | RO; notice | PASS (vacuous) |
|
||||||
|
| C29 | `pest --tia --shard=1/2` | RO; notice; no prune | PASS |
|
||||||
|
| C30 | `pest --tia --shard=2/2` | RO; notice; C29+C30 covers the suite; neither prunes | PASS |
|
||||||
|
| C31 | `->only()` on `trio one`, `pest --tia` | RO; no notice; siblings untouched | PASS |
|
||||||
|
| C32 | `->only()` on `trio one`, plain `pest` | RO; no notice | PASS |
|
||||||
|
| C33 | `pest --tia --filtered --filter=adds` | RO; notice; filtered yields to narrowing | PASS |
|
||||||
|
| C34 | `PEST_TIA=1 pest --filter=adds` | RO; notice | PASS |
|
||||||
|
| C35 | `PEST_TIA_FILTERED=1 pest --filter=adds` | RO; notice (regression guard) | PASS |
|
||||||
|
| C36 | `pest --filtered --filter=adds` | RO; notice | PASS |
|
||||||
|
| C37 | `pest --tia --ticket=X` | RO; notice. `--ticket` **is** a real option (`src/Plugins/Snapshot.php:83`) — it narrows legitimately | PASS (vacuous) |
|
||||||
|
| C38 | `pest --tia --assignee=X` | RO; notice (`src/Plugins/Snapshot.php:81`) | PASS (vacuous) |
|
||||||
|
| C39 | `pest --tia --todo` | RO; notice; matches nothing | PASS (vacuous) |
|
||||||
|
|
||||||
|
### D — Truncation → RESULTS-ONLY
|
||||||
|
|
||||||
|
D1–D5, D11–D13, D16 precondition: `trio one` broken. D6–D10 run against a green suite so the `--stop-on-*` trigger is the only narrowing.
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | `pest --bail` | RO; `trio one` = `status=7`; siblings survive unchanged | PASS |
|
||||||
|
| D2 | `pest --retry` | RO; siblings survive | PASS |
|
||||||
|
| D3 | `pest --stop-on-failure` | RO | PASS |
|
||||||
|
| D4 | `pest --stop-on-defect` | RO | PASS |
|
||||||
|
| D5 | `pest --stop-on-error` (no error occurs) | COMPLETE — nothing stopped it | PASS |
|
||||||
|
| D6 | `pest --stop-on-warning` | RO when it fires. Warning stored as `status=0`, not `6` | PASS |
|
||||||
|
| D7 | `pest --stop-on-risky --disallow-test-output` | RO; `status=5` | PASS |
|
||||||
|
| D8 | `pest --stop-on-skipped` | RO; `status=1` | PASS |
|
||||||
|
| D9 | `pest --stop-on-incomplete` | RO; `status=2` | PASS |
|
||||||
|
| D10 | `pest --stop-on-deprecation` | RO. Deprecation stored as `status=0`, not `4` | PASS |
|
||||||
|
| D11 | `pest --tia --bail` | RO | PASS |
|
||||||
|
| D12 | `pest --bail --filter=trio` | RO (narrowed *and* truncated) | PASS |
|
||||||
|
| D13 | `stopOnFailure="true"` in `phpunit.xml`, plain `pest` | RO — caught via `stoppedEarly()`, not flag matching | PASS |
|
||||||
|
| D14 | D13 config, green suite | COMPLETE | PASS |
|
||||||
|
| D15 | `pest --bail`, last test in run order fails | RO — over-conservative but safe; nothing was skipped | PASS |
|
||||||
|
| D16 | `pest --tia --filtered --bail`, broken affected test | RO | PASS |
|
||||||
|
|
||||||
|
### E — Result merge semantics
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E1 | cached `flaky`=7, `FLAKY_OK=1 pest --filter=flaky` | flips to `status=0`; all other results byte-identical | PASS |
|
||||||
|
| E2 | cached `flaky`=0, `pest --filter=flaky` (env unset) | flips to `status=7` | PASS |
|
||||||
|
| E3 | after E1, `pest --tia --filtered` clean | `No affected tests found`; zero graph delta | PASS |
|
||||||
|
| E4 | after E2, `pest --tia --filtered` | re-runs, `from 1 previously unsuccessful test` | PASS |
|
||||||
|
| E5 | any partial run | `assertions` and `time` update for the test that ran | PASS |
|
||||||
|
| E6 | any partial run | `message` of untouched tests unchanged | PASS |
|
||||||
|
| E7 | two sequential partial runs on different tests | each updates only its own entry; both persist | PASS |
|
||||||
|
| E8 | `pest --filter="trio one"` | siblings keep exact `status`/`time`/`assertions`/`message` | PASS |
|
||||||
|
| E9 | partial run of a `->skip()`ed test | `status=1` | PASS |
|
||||||
|
| E10 | partial run of a `->todo()` test | `status=1`, `assertions=0`, `message="__TODO__"` | PASS |
|
||||||
|
| E11 | partial run of a risky test | `status=5` | PASS |
|
||||||
|
| E12 | any partial run | `fingerprint` byte-identical | PASS |
|
||||||
|
| E13 | dataset test, `--filter` matching one row | that row updates; other rows survive (the old prune bug) | PASS |
|
||||||
|
| E14 | dataset test, full `--tia` after deleting a row | the deleted row is pruned | PASS |
|
||||||
|
|
||||||
|
### F — Guard rails
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F1 | test absent from `edges`, `pest --filter="brand new"` | no result recorded | PASS |
|
||||||
|
| F2 | complete `pest --tia` first, then the same filter | result is recorded | PASS |
|
||||||
|
| F3 | `pest --mutate --path=app/Services --covered-only` | statuses/`edges`/`sha`/`tree`/`files` identical; only `time` may change | PASS |
|
||||||
|
| F4 | `pest --mutate --parallel --path=… --covered-only` | same (observed: zero delta) | PASS |
|
||||||
|
| F5 | weakened test so a mutant survives, `pest --mutate` | no status change in the baseline | PASS |
|
||||||
|
| F6 | `pest --mutate` with the graph deleted | no graph created | PASS |
|
||||||
|
| F7 | mutation subprocess env | carries `PEST_MUTATION_TESTING`; `--mutate` stripped from argv | PASS |
|
||||||
|
| F8 | grep baseline after `--mutate` | no mutation-flavoured messages | PASS |
|
||||||
|
|
||||||
|
### G — Parallel
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G1 | `pest --tia --parallel` (clean) | COMPLETE — graph written | PASS |
|
||||||
|
| G2 | `pest --tia --parallel --filter=adds` | RO | PASS |
|
||||||
|
| G3 | `pest --parallel --bail`, `trio one` broken | RO; siblings survive | PASS |
|
||||||
|
| G4 | `pest --tia --parallel --bail` | RO | PASS |
|
||||||
|
| G5 | `pest --tia --filtered --parallel` after a source edit | narrows to affected; writes | PASS (but see G12 — it strips edges) |
|
||||||
|
| G6 | `pest --tia --parallel` | worker results reach the parent baseline | PASS |
|
||||||
|
| G7 | `->only()` + `pest --tia --parallel` | RO | PASS |
|
||||||
|
| G8 | `pest --tia --parallel --shard=1/2` | RO | PASS |
|
||||||
|
| G9 | broken test in one worker, `pest --parallel --bail` | whole run is RO, not just that worker's slice | PASS |
|
||||||
|
| G10 | `pest --parallel --retry` | `InvalidOption`; graph untouched | PASS |
|
||||||
|
| G11 | `pest --tia --parallel --fresh` | graph rebuilt | PASS |
|
||||||
|
| G12 | `edges` after `pest --tia` vs `pest --tia --parallel --fresh` | **equivalent edge sets** (`files=25` both) | **FIX (item 1)** |
|
||||||
|
|
||||||
|
### H — Baseline key / branch resolution
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| H1 | `pest --tia` on `master` | only a `master` key | PASS |
|
||||||
|
| H2 | plain `pest` on `master` | writes `master`, not `main` | PASS |
|
||||||
|
| H3 | `pest --bail` truncated on `master` | no `main` key minted | PASS |
|
||||||
|
| H4 | `pest --filter=adds` on `master` | no `main` key minted | PASS |
|
||||||
|
| H5 | `git branch -m main`, full `pest --tia` | single `main` key | PASS |
|
||||||
|
| H6 | `git checkout -b feature/x`, `pest --tia` | `feature/x` key appears; reads fall back to the existing baseline | PASS |
|
||||||
|
| H7 | detached HEAD, `pest --tia` | falls back to the `main` key for both reads and writes; no `HEAD` key | PASS |
|
||||||
|
| H8 | after every C and D case | no baseline key other than the real branch | PASS |
|
||||||
|
| H9 | non-git dir, `pest --tia` | `MissingDependency` — `The feature "Tia mode" requires "git".` | PASS |
|
||||||
|
| H10 | non-git dir, plain `pest` | runs normally; no baseline dir created | PASS |
|
||||||
|
|
||||||
|
### I — Filtered mode
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| I1 | clean + green, `pest --tia --filtered` | `No affected tests found`; zero graph delta | PASS |
|
||||||
|
| I2 | edit `Calculator.php` | only `CalculatorTest` runs | PASS |
|
||||||
|
| I3 | cached failure, clean tree | re-runs it, `from 1 previously unsuccessful test` | PASS |
|
||||||
|
| I4 | I2 with sentinels | `tree` updated; exactly one entry rewritten; all others retained | PASS |
|
||||||
|
| I5 | `pest --tia --filtered --parallel` | same narrowing as I2 **and `edges` unchanged** | **FIX (item 1)** |
|
||||||
|
| I6 | `PEST_TIA_FILTERED=1 pest --tia` | identical delta to I4 | PASS |
|
||||||
|
| I7 | `pest --tia --filtered tests/Unit` | explicit path wins; filtered off; RO; notice | PASS |
|
||||||
|
| I8 | `pest --tia --filtered --coverage-text` | filtered mode disabled by an active coverage report | **FIX (item 3)** |
|
||||||
|
| I9 | cached failure whose test file was deleted | WARN `could not be located on disk`; falls back to the full suite with replay | **FIX (item 2)** |
|
||||||
|
| I10 | `pest --tia --filtered` with no baseline yet | records a baseline instead of filtering | PASS |
|
||||||
|
| I11 | edit a Blade view a Feature test renders | the Feature test is selected | PASS |
|
||||||
|
| I12 | edit `composer.lock` | fingerprint drift → full rebuild (and the drift reason should be printed — item 5) | PASS |
|
||||||
|
|
||||||
|
### J — Interactions & regressions
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| J1 | `pest --tia --fresh --filter=adds` | graph **not** purged; RO | PASS |
|
||||||
|
| J2 | `pest --tia --refetch --filter=adds` | refetch not performed; RO | PASS |
|
||||||
|
| J3 | `pest --no-tia --filter=adds` | RO — narrowing wins over `--no-tia`; no notice | PASS |
|
||||||
|
| J4 | `pest --tia --no-tia` | TIA disabled; COMPLETE | PASS |
|
||||||
|
| J5 | `<groups><exclude><group>integration</group></exclude></groups>`, `pest --tia` | COMPLETE, no notice — an always-in-force filter applies to baseline runs too | PASS |
|
||||||
|
| J6 | `pest --tia --compact` | COMPLETE | PASS |
|
||||||
|
| J7 | `pest --tia -v` | COMPLETE | PASS |
|
||||||
|
| J8 | `pest --tia --profile` | COMPLETE | PASS |
|
||||||
|
| J9 | `pest --tia --order-by=random` | COMPLETE | PASS |
|
||||||
|
| J10 | `pest --tia --random-order-seed=1234` | COMPLETE | PASS |
|
||||||
|
| J11 | `pest --tia --repeat=2` | COMPLETE — repetition is not narrowing | **SKIP** — `--repeat` is not a Pest option |
|
||||||
|
| J12 | `pest --filter=adds` twice | only the executed test's `time` differs; rest byte-identical | PASS |
|
||||||
|
| J13 | `pest --tia --min=50` | COMPLETE | PASS |
|
||||||
|
| J14 | SIGINT mid-suite | RO — an interrupted run is truncated | PASS (signal must go to the re-exec'd child — item 5) |
|
||||||
|
| J15 | delete the graph, `pest --tia --filtered` | records a baseline rather than erroring | PASS |
|
||||||
|
| J16 | corrupt `graph.json`, `pest --tia` | recovers by rebuilding; no crash | PASS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Re-run procedure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nunomaduro/Work/projects/playground/laravel
|
||||||
|
GRAPH="$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)/graph.json"
|
||||||
|
|
||||||
|
# per case
|
||||||
|
git checkout -q . && git clean -qfd tests app
|
||||||
|
rm -rf "$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)"
|
||||||
|
PAO_DISABLE=1 ./vendor/bin/pest --tia >/dev/null 2>&1
|
||||||
|
# sentinel-patch every result to time=9.999 assertions=42, snapshot, run the case, diff
|
||||||
|
```
|
||||||
|
|
||||||
|
Every pest invocation must be prefixed with `PAO_DISABLE=1` (the app has `laravel/pao`, which emits
|
||||||
|
JSON when it detects an agent). The shell is zsh — build commands with arrays or `eval`; unquoted
|
||||||
|
`$args` does not word-split.
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
# TIA default-branch fallback — phase three
|
||||||
|
|
||||||
|
## Your task
|
||||||
|
|
||||||
|
Fix [pestphp/pest#1823](https://github.com/pestphp/pest/issues/1823) — TIA's cached results never
|
||||||
|
hit for repos whose default branch is not literally `main` — then re-run the **whole** conformance
|
||||||
|
matrix against the playground app, plus the new section **L** that covers the fix.
|
||||||
|
|
||||||
|
Work in this order, and **stop where the plan says stop**:
|
||||||
|
|
||||||
|
1. **Part 1** — read the diagnosis. It is already measured; do not re-derive it, but do re-confirm
|
||||||
|
the two reproductions in Part 1.3 take ~2 minutes and prove your environment is sane.
|
||||||
|
2. **Part 2** — implement the fix in the `pestphp/pest` repo. **Do not commit. Do not touch the
|
||||||
|
playground's `vendor/`.**
|
||||||
|
3. **HARD STOP → Part 3.** Report the diff to Nuno and wait. He validates, commits, and syncs it
|
||||||
|
into the playground himself. You must not proceed until he confirms.
|
||||||
|
4. **Part 4** — verify the sync landed, then run section **L** (new) and the full phase-two matrix
|
||||||
|
(**A–K**, all 156 rows) against the playground.
|
||||||
|
5. **Part 5** — report in the given format.
|
||||||
|
|
||||||
|
Per `CLAUDE.md`: **do not write new `pestphp/pest` unit tests and do not run `composer test`.** Make
|
||||||
|
the change, report it, and ask whether repo tests should be added. The section-L rows are *playground
|
||||||
|
invocations*, not repo tests — those are the deliverable and are always in scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — The diagnosis (already measured; commit `bfd5b756`)
|
||||||
|
|
||||||
|
### 1.1 Root cause — two independent hardcoded `'main'` literals
|
||||||
|
|
||||||
|
**(a) The read fallback.** `src/Plugins/Tia/Graph.php` — seven methods default the fallback to the
|
||||||
|
literal `'main'`:
|
||||||
|
|
||||||
|
| line | method |
|
||||||
|
|---|---|
|
||||||
|
| 579 | `recordedAtSha(string $branch, string $fallbackBranch = 'main')` |
|
||||||
|
| 614 | `getAssertions(…, string $fallbackBranch = 'main')` |
|
||||||
|
| 625 | `getTime(…, string $fallbackBranch = 'main')` |
|
||||||
|
| 636 | `getResult(…, string $fallbackBranch = 'main')` |
|
||||||
|
| 663 | `testFilesToRerun(string $branch, string $fallbackBranch = 'main')` |
|
||||||
|
| 700 | `hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main')` |
|
||||||
|
| 811 | `lastRunTree(string $branch, string $fallbackBranch = 'main')` |
|
||||||
|
|
||||||
|
They all funnel into `Graph::baselineFor()` (line 819), which *does* implement a real cross-branch
|
||||||
|
fallback:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (isset($this->baselines[$branch])) return $this->baselines[$branch];
|
||||||
|
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) return $this->baselines[$fallbackBranch];
|
||||||
|
return ['sha' => null, 'tree' => [], 'results' => []];
|
||||||
|
```
|
||||||
|
|
||||||
|
The mechanism is deliberate. The problem is that **no caller ever passes `$fallbackBranch`** — all
|
||||||
|
nine call sites in `src/Plugins/Tia.php` (lines 419, 429, 432, 785, 857, 1026, 1031, 1048, 1056) pass
|
||||||
|
only `$this->branch`. So on a `master`-named repo the second branch of `baselineFor()` can never
|
||||||
|
fire, and the whole mechanism is dead code.
|
||||||
|
|
||||||
|
`grep -rniE 'defaultBranch|symbolic-ref|init\.defaultBranch|origin/HEAD' src/` returns **nothing** —
|
||||||
|
no default-branch resolution exists anywhere.
|
||||||
|
|
||||||
|
**(b) The detached-HEAD default.** `src/Plugins/Tia.php:206` declares `private string $branch = 'main';`
|
||||||
|
and `ChangedFiles::currentBranch()` (`ChangedFiles.php:208`) returns `null` for detached HEAD. So on
|
||||||
|
detached HEAD `$this->branch` stays the literal `'main'` and is used for **both reads and writes** —
|
||||||
|
minting a baseline key for a branch that does not exist. This is a *write*-side bug and a separate fix
|
||||||
|
from (a).
|
||||||
|
|
||||||
|
### 1.2 Not a regression
|
||||||
|
|
||||||
|
`git log -S"fallbackBranch = 'main'"` bottoms out at `c7e32f5d feat(tia): continues to work on poc`.
|
||||||
|
This is original PoC code, untouched by phase one. Phase one's change 1 modified
|
||||||
|
`hasUnlocatedTestsToRerun()`'s file-existence check — one of the seven methods — without going near
|
||||||
|
the fallback. Do not report it as a phase-one regression.
|
||||||
|
|
||||||
|
No test anywhere exercises the mechanism: `tests/Unit/Plugins/Tia/Graph.php` uses `'main'` as the
|
||||||
|
*actual* branch name, so those assertions pass whether or not the fallback exists. A branch-name
|
||||||
|
mismatch is never tested.
|
||||||
|
|
||||||
|
### 1.3 The two reproductions (re-confirm these before you start)
|
||||||
|
|
||||||
|
Both on the playground, `master`-named default branch, zero local changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
default = master default = main
|
||||||
|
1. record on default → full run (cold) → full run (cold)
|
||||||
|
2. 1st run on feature-x → 25 UNCACHED → 25 replayed ← (a)
|
||||||
|
3. 2nd run on feature-x → 25 replayed → 25 replayed
|
||||||
|
4. back on default → 25 replayed → 25 replayed
|
||||||
|
5. 1st run on feature-y → 25 UNCACHED → 25 replayed
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
master-only graph, then `git checkout --detach`, then `pest --tia`:
|
||||||
|
→ 25 uncached, and keys become [master,main] ← (b) spurious key
|
||||||
|
```
|
||||||
|
|
||||||
|
The cost is **one full run per new branch, forever**, with no output explaining why — the only clue
|
||||||
|
is the `N uncached` count; the headline is just `─ Experimental TIA mode enabled.`
|
||||||
|
|
||||||
|
### 1.4 Correction to phase two
|
||||||
|
|
||||||
|
Phase two reported **H6 and H7 as passing. Both were false passes.** They ran after H5, which had
|
||||||
|
renamed `master`→`main` and left a `main` key in the graph, so the hardcoded fallback resolved by
|
||||||
|
accident. Re-measured against a clean `master`-only graph, both fail. Section L replaces them as the
|
||||||
|
load-bearing rows; H6/H7 must be re-run **from a cold graph** this time (see Part 4.2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — The fix to implement
|
||||||
|
|
||||||
|
### 2.1 Recommended design
|
||||||
|
|
||||||
|
**Step 1 — add a non-throwing resolver** to `src/Plugins/Tia/ChangedFiles.php`, next to
|
||||||
|
`currentBranch()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution order, each step failing soft to the next:
|
||||||
|
|
||||||
|
1. `git symbolic-ref --short refs/remotes/origin/HEAD` → strip a leading `origin/`
|
||||||
|
2. `git config --get init.defaultBranch`
|
||||||
|
3. `null`
|
||||||
|
|
||||||
|
Unlike `currentBranch()`, this must **never throw** `MissingDependency` — it is advisory. Return
|
||||||
|
`null` on any non-zero exit or empty output.
|
||||||
|
|
||||||
|
**Step 2 — add a config surface.** `src/Plugins/Tia/Configuration.php` already exposes `always()`,
|
||||||
|
`locally()`, `filtered()`, `baselined()`, `watch()`. Add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function defaultBranch(string $branch): self
|
||||||
|
```
|
||||||
|
|
||||||
|
so `pest()->tia()->defaultBranch('master')` works in `tests/Pest.php`. Explicit config **always
|
||||||
|
wins** over autodetection — that is the escape hatch when `origin/HEAD` is unset.
|
||||||
|
|
||||||
|
**Step 3 — resolve once, in `Tia.php`.** The read path is hot (`getResult()` is called per test at
|
||||||
|
line 419), so resolution must not shell out per call. Resolve alongside `$this->branch` at
|
||||||
|
`Tia.php:1910`, under the existing `$branchResolved` guard:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$this->fallbackBranch = $configuredDefaultBranch
|
||||||
|
?? $changedFiles->defaultBranch()
|
||||||
|
?? 'main';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 — thread it into `Graph`.** Prefer a `Graph`-level property over editing nine call sites:
|
||||||
|
add `Graph::setFallbackBranch(string $branch)`, change the seven signatures to
|
||||||
|
`?string $fallbackBranch = null`, and resolve inside each with
|
||||||
|
`$fallbackBranch ??= $this->fallbackBranch;`. `baselineFor()` itself needs no change. This keeps the
|
||||||
|
public signatures backward-compatible and minimises blast radius.
|
||||||
|
|
||||||
|
**Step 5 — fix the detached-HEAD write.** `Tia.php:206`'s `= 'main'` default must become the
|
||||||
|
resolved default branch, so detached HEAD stops minting a phantom key.
|
||||||
|
|
||||||
|
### 2.2 Invariants the fix must not break
|
||||||
|
|
||||||
|
These are all covered by existing matrix rows — the fix is wrong if any of them moves:
|
||||||
|
|
||||||
|
- **Read-only.** The fallback must affect *reads* only. Writes go through `ensureBaseline($branch)`
|
||||||
|
and must keep using the real current branch. Otherwise H1–H4/H8 ("no baseline key other than the
|
||||||
|
real branch") break.
|
||||||
|
- **H9** — a non-git dir with `--tia` must still raise
|
||||||
|
`MissingDependency: The feature "Tia mode" requires "git".` Adding a soft resolver must not
|
||||||
|
swallow that.
|
||||||
|
- **H10** — plain `pest` in a non-git dir must still run and create no baseline dir.
|
||||||
|
- **A2/A3** — cold-graph recording unchanged.
|
||||||
|
- **I1/E3** — clean+green `--tia --filtered` must still be a true zero-delta run.
|
||||||
|
- Filtered mode reads `testFilesToRerun()` and `hasUnlocatedTestsToRerun()`, so the fallback must
|
||||||
|
reach those two as well, not just `getResult()`.
|
||||||
|
|
||||||
|
### 2.3 Decisions for Nuno (raise these at the Part 3 stop)
|
||||||
|
|
||||||
|
- **D1** — Consult `origin/HEAD` at all? It requires `git remote set-head` and is absent in many CI
|
||||||
|
checkouts and all remote-less repos. Config + `init.defaultBranch` only is simpler but helps fewer
|
||||||
|
people out of the box. *Recommendation: keep it, first in the chain, since it fails soft.*
|
||||||
|
- **D2** — Single-key heuristic: if the graph holds exactly one baseline key, use it as the fallback?
|
||||||
|
Fixes the issue with zero git calls, but is implicit and surprising when several keys exist.
|
||||||
|
*Recommendation: no.*
|
||||||
|
- **D3** — Should detached HEAD write a baseline at all, or be read-only? Current behaviour mints a
|
||||||
|
key. *Recommendation: read-only.*
|
||||||
|
- **D4** — Should `pest()->tia()->defaultBranch()` validate that the branch exists, or accept any
|
||||||
|
string? *Recommendation: accept any string; a nonexistent name degrades to a full run, which is
|
||||||
|
safe.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — HARD STOP
|
||||||
|
|
||||||
|
When the code is written:
|
||||||
|
|
||||||
|
1. Show Nuno the diff (`git -C /Users/nunomaduro/Work/projects/pestphp/pest diff`) and a one-paragraph
|
||||||
|
summary of each file's change.
|
||||||
|
2. Answer/raise the D1–D4 decisions.
|
||||||
|
3. State explicitly that you have **not** committed and have **not** synced `vendor/`.
|
||||||
|
4. Ask whether repo unit tests should be added (per `CLAUDE.md`), describing the tests you have in
|
||||||
|
mind — do not write them yet.
|
||||||
|
5. **Wait.** Nuno commits and applies the change to the playground.
|
||||||
|
|
||||||
|
**Never sync the playground's `vendor/` yourself.** `vendor/pestphp/pest` there is a dist copy, not a
|
||||||
|
symlink (composer installed `dev-fix/tia-filtered as 5.2.0`), so pest-repo edits do not reach it. Say
|
||||||
|
what is stale and wait. This applies to before/after contrasts too.
|
||||||
|
|
||||||
|
Once he confirms, verify the sync actually landed before measuring anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
V=/Users/nunomaduro/Work/projects/playground/laravel/vendor/pestphp/pest
|
||||||
|
grep -c 'defaultBranch' "$V/src/Plugins/Tia/ChangedFiles.php" # must be ≥ 1
|
||||||
|
for f in src/Plugins/Tia.php src/Plugins/Tia/Graph.php src/Plugins/Tia/ChangedFiles.php \
|
||||||
|
src/Plugins/Tia/Configuration.php; do
|
||||||
|
diff -q "/Users/nunomaduro/Work/projects/pestphp/pest/$f" "$V/$f" >/dev/null \
|
||||||
|
&& echo "SAME $f" || echo "STALE $f"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
If anything reports `STALE`, stop and tell him. State in your final report which pest commit produced
|
||||||
|
the playground numbers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — Measurement
|
||||||
|
|
||||||
|
### 4.1 Environment and traps
|
||||||
|
|
||||||
|
**Playground:** `/Users/nunomaduro/Work/projects/playground/laravel` (branch `master`).
|
||||||
|
|
||||||
|
1. **Every** pest invocation needs `PAO_DISABLE=1` — the app has `laravel/pao`, which emits JSON when
|
||||||
|
it detects an agent.
|
||||||
|
2. **Pin the interpreter to `php85`.** The playground requires PHP `>= 8.4.1` *and* pcov. Locally only
|
||||||
|
`php85` (8.5.8) has both — `php84` (8.4.23) has no pcov, and the bare Herd `php` shim has been
|
||||||
|
observed drifting to 8.3.32 mid-session, which kills every run in
|
||||||
|
`vendor/composer/platform_check.php`. Also put a `php` → `php85` symlink first on `PATH`: the
|
||||||
|
`--shard` list-tests probe spawns a subprocess via bare `php`, not `PHP_BINARY`.
|
||||||
|
3. **Never run `git checkout .` or `git checkout -- composer.lock` in the playground.** It has four
|
||||||
|
pre-existing user-modified files — `AGENTS.md`, `CLAUDE.md`, `composer.json`, `composer.lock`. Scope
|
||||||
|
resets to `git checkout -- tests app` / `git clean -fd tests app`. For `phpunit.xml` and
|
||||||
|
`composer.lock`, copy aside and copy back, verifying with `shasum`.
|
||||||
|
4. **zsh does not word-split unquoted parameters.** A `$PEST` string containing a space becomes one
|
||||||
|
command name. Route every invocation through `eval` (the `pest()` helper below does this).
|
||||||
|
5. Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||||
|
6. **The sentinel technique must not falsify `assertions` on zero-assertion tests.** A
|
||||||
|
risky/skipped/incomplete status is *derived* from "performed no assertions", so patching those to
|
||||||
|
`42` rewrites the status on replay and destroys the discriminator — it shows up as a phantom
|
||||||
|
`status 5→0` defect. The `sentinel.php` below only patches `assertions` where it is already
|
||||||
|
non-zero. With that, a full replay gives a clean `rewritten=0`.
|
||||||
|
7. Restore branch state after every L row. Several rows rename or detach; leaving a stray `main` key
|
||||||
|
in the graph is exactly what produced phase two's false H6/H7 passes.
|
||||||
|
|
||||||
|
### 4.2 Reference numbers
|
||||||
|
|
||||||
|
The playground already carries the phase-two fixtures at commit `fb2e77e` — **do not rebuild them.**
|
||||||
|
A healthy sequential `--tia` graph is:
|
||||||
|
|
||||||
|
> **`files=27`, 10 edge keys (one per test file), self-edge on all 10, `n=25` results.**
|
||||||
|
|
||||||
|
`sha` will differ once Nuno commits the vendor sync — re-derive it once and use it throughout. The
|
||||||
|
`files`/`edges`/`n` numbers hold as long as no test fixture changes. Suite shape: 10 test files, 25
|
||||||
|
tests, including six deliberate status fixtures (skipped, todo, incomplete, risky, warning,
|
||||||
|
deprecation), a 3-row dataset, a `smoke` group, an env-driven flaky test (green unless
|
||||||
|
`FLAKY_FAIL=1`), and the annotation set (`covers`/`note`/`flaky`/`issue`/`pr`/`ticket`/`assignee`).
|
||||||
|
|
||||||
|
**Re-run H6 and H7 from a cold graph** (`rm -rf` the graph dir, record on `master` only, *then*
|
||||||
|
branch/detach). Their phase-two results are void.
|
||||||
|
|
||||||
|
### 4.3 Harness
|
||||||
|
|
||||||
|
Write these to your scratchpad. `$SP` is your own scratchpad dir.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>lib.sh</code></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/zsh
|
||||||
|
export PAO_DISABLE=1
|
||||||
|
PG=/Users/nunomaduro/Work/projects/playground/laravel
|
||||||
|
SP="<your scratchpad>"
|
||||||
|
PHPBIN="php85"
|
||||||
|
PEST="$PHPBIN $PG/vendor/bin/pest"
|
||||||
|
cd "$PG" || exit 1
|
||||||
|
pest() { eval "$PEST $*"; } # zsh: no word-splitting, must eval
|
||||||
|
GRAPHDIR="$(pest --baseline)"
|
||||||
|
GRAPH="$GRAPHDIR/graph.json"
|
||||||
|
mkdir -p "$SP/bin" && ln -sf "$(command -v php85)" "$SP/bin/php"
|
||||||
|
export PATH="$SP/bin:$PATH" # --shard spawns bare `php`
|
||||||
|
reset_tree() { git checkout -- tests app 2>/dev/null; git clean -qfd tests app 2>/dev/null; }
|
||||||
|
seed() { rm -rf "$GRAPHDIR"; pest --tia >/dev/null 2>&1; $PHPBIN "$SP/sentinel.php" "$GRAPH" >/dev/null; cp "$GRAPH" "$SP/before.json"; }
|
||||||
|
snap() { cp "$GRAPH" "$SP/before.json"; }
|
||||||
|
delta() { $PHPBIN "$SP/cmp.php" "$SP/before.json" "$GRAPH"; }
|
||||||
|
keys() { $PHPBIN -r '$g=json_decode(file_get_contents($argv[1]),true);echo "[".implode(",",array_keys($g["baselines"]??[]))."]";' "$GRAPH"; }
|
||||||
|
tally() { sed -E $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' <"$SP/out.txt" | grep -E 'Tests:' | sed -E 's/^ +//;s/Tests: +//'; }
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>sentinel.php</code> — the write discriminator</summary>
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php // sentinel.php <graph.json>
|
||||||
|
$p = $argv[1];
|
||||||
|
$g = json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$n = 0;
|
||||||
|
foreach ($g['baselines'] ?? [] as $br => $b) {
|
||||||
|
foreach (array_keys($b['results'] ?? []) as $id) {
|
||||||
|
$g['baselines'][$br]['results'][$id]['time'] = 9.999;
|
||||||
|
// Only falsify a non-zero assertion count: risky/skipped/incomplete are
|
||||||
|
// DERIVED from "performed no assertions", so patching those to 42 would
|
||||||
|
// rewrite the status on replay and destroy the discriminator.
|
||||||
|
if ((int) ($b['results'][$id]['assertions'] ?? 0) > 0) {
|
||||||
|
$g['baselines'][$br]['results'][$id]['assertions'] = 42;
|
||||||
|
}
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_put_contents($p, json_encode($g, JSON_THROW_ON_ERROR));
|
||||||
|
echo "sentinelled $n results\n";
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><code>oneline.php</code> — one compact tier verdict per row</summary>
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php // oneline.php <before.json> <after.json>
|
||||||
|
function load(string $p): ?array {
|
||||||
|
return is_file($p) ? json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR) : null;
|
||||||
|
}
|
||||||
|
$a = load($argv[1]); $b = load($argv[2]);
|
||||||
|
if ($a === null || $b === null) { echo 'GRAPH '.($b === null ? 'DELETED' : 'CREATED'); exit; }
|
||||||
|
function edgeSets(array $g): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($g['edges'] ?? [] as $t => $ids) { $s = array_map(fn($i) => $g['files'][$i] ?? "?$i", (array) $ids); sort($s); $out[$t] = $s; }
|
||||||
|
ksort($out); return $out;
|
||||||
|
}
|
||||||
|
$moved = [];
|
||||||
|
if (edgeSets($a) !== edgeSets($b)) $moved[] = 'edges';
|
||||||
|
if (($a['files'] ?? []) !== ($b['files'] ?? [])) $moved[] = 'files';
|
||||||
|
if (($a['fingerprint'] ?? null) !== ($b['fingerprint'] ?? null)) $moved[] = 'fingerprint';
|
||||||
|
$brA = array_keys($a['baselines'] ?? []); $brB = array_keys($b['baselines'] ?? []);
|
||||||
|
if ($brA !== $brB) $moved[] = 'branchkeys('.implode('|', $brA).'->'.implode('|', $brB).')';
|
||||||
|
$add = $rem = $wr = 0; $shaMoved = $treeMoved = false;
|
||||||
|
foreach ($brB as $br) {
|
||||||
|
$ra = $a['baselines'][$br]['results'] ?? []; $rb = $b['baselines'][$br]['results'] ?? [];
|
||||||
|
if (($a['baselines'][$br]['sha'] ?? null) !== ($b['baselines'][$br]['sha'] ?? null)) $shaMoved = true;
|
||||||
|
if (($a['baselines'][$br]['tree'] ?? null) !== ($b['baselines'][$br]['tree'] ?? null)) $treeMoved = true;
|
||||||
|
$add += count(array_diff(array_keys($rb), array_keys($ra)));
|
||||||
|
$rem += count(array_diff(array_keys($ra), array_keys($rb)));
|
||||||
|
foreach ($ra as $id => $x) {
|
||||||
|
if (! isset($rb[$id])) continue;
|
||||||
|
foreach (['status','time','assertions','message'] as $f) {
|
||||||
|
if (($x[$f] ?? null) !== ($rb[$id][$f] ?? null)) { $wr++; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$n = 0; foreach ($brB as $br) $n = max($n, count($b['baselines'][$br]['results'] ?? []));
|
||||||
|
printf('n=%d w=%-2d +%d -%d %s%s%s', $n, $wr, $add, $rem,
|
||||||
|
$moved === [] ? 'struct:ok' : 'STRUCT:'.implode(',', $moved),
|
||||||
|
$shaMoved ? ' sha:CHANGED' : '', $treeMoved ? ' tree:chg' : '');
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
`cmp.php` (verbose per-entry version of the same) and `summarise.php` / `edgediff.php` are in
|
||||||
|
`PLAN_PHASE_TWO.md` §"Graph summariser" — reuse them for drill-downs. Reading the verdict:
|
||||||
|
|
||||||
|
- `w=` — entries actually **written**. Under sentinel patching this is the only reliable way to tell
|
||||||
|
"wrote identical values" from "wrote nothing". A full replay must give `w=0`.
|
||||||
|
- `struct:ok` + `+0 -0` — no prune, no edges/files/fingerprint movement. Required by RESULTS-ONLY.
|
||||||
|
- `STRUCT:branchkeys(...)` — a new baseline key appeared. For section L this is the headline signal.
|
||||||
|
|
||||||
|
### 4.4 Section L — new rows for this fix
|
||||||
|
|
||||||
|
Tiers, unchanged from phase two: **COMPLETE** may change everything · **RESULTS-ONLY (RO)** may
|
||||||
|
change only `baselines[<branch>].results` for tests that ran, and must never remove an entry, add a
|
||||||
|
result for a test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||||
|
**HARD-SUPPRESSED** may change nothing.
|
||||||
|
|
||||||
|
Every L row starts from a **cold graph recorded on the named default branch only** — verify
|
||||||
|
`keys=[<default>]` before branching. Restore branch state afterwards.
|
||||||
|
|
||||||
|
| # | Case | Target outcome |
|
||||||
|
|---|---|---|
|
||||||
|
| L1 | default `master`, record, `git switch -c feature-x`, `pest --tia`, zero changes | **all 25 replayed** (`w=0`), not `25 uncached`. The headline fix. |
|
||||||
|
| L2 | as L1 but default `main` | still all replayed — regression guard, this already worked |
|
||||||
|
| L3 | default `trunk`, then `develop` | replayed for both; the fix must not special-case two names |
|
||||||
|
| L4 | L1, then a *second* new branch `feature-y` | replayed too — the toll must not return per branch |
|
||||||
|
| L5 | L1 then edit `app/Services/Calculator.php` on `feature-x` | narrows to the 2 affected files (`CalculatorTest` + `AnnotationsTest`, which `covers` it); the other 17 replay |
|
||||||
|
| L6 | L1 with `--tia --filtered` | filtered mode reads the fallback too (`testFilesToRerun`, `hasUnlocatedTestsToRerun`) → `No affected tests found`, zero delta |
|
||||||
|
| L7 | L1 with `--tia --parallel` | fallback works in workers as well as the parent |
|
||||||
|
| L8 | L1, then `pest --tia` twice on `feature-x` | idempotent; second run also `w=0` |
|
||||||
|
| L9 | detached HEAD on a `master`-only graph | replays, and **no `main` key minted** — `keys` stays `[master]`. Bug (b). |
|
||||||
|
| L10 | `pest()->tia()->defaultBranch('master')` in `tests/Pest.php`, repo default renamed away | config wins over autodetect |
|
||||||
|
| L11 | config set to a nonexistent branch (`defaultBranch('nope')`) | degrades to a full run; no crash, no phantom key |
|
||||||
|
| L12 | no remote at all (`git remote remove origin` if present) | still resolves (via `init.defaultBranch`) or degrades safely — must not throw |
|
||||||
|
| L13 | branch name with a slash (`feature/x/y`) | replayed; no key-splitting bugs |
|
||||||
|
| L14 | L1, then confirm writes | `feature-x` gets its **own** key; the `master` key is **not** written to (fallback is read-only) |
|
||||||
|
| L15 | git worktree on a new branch (the issue's scenario) | replays from the default-branch baseline |
|
||||||
|
| L16 | non-git dir, `pest --tia` | still `MissingDependency: The feature "Tia mode" requires "git".` — the soft resolver must not swallow it |
|
||||||
|
| L17 | non-git dir, plain `pest` | runs normally; no baseline dir created |
|
||||||
|
| L18 | count `git` subprocesses during one `--tia` run | default-branch resolution is cached, not one call per test. Probe by shimming `git` on `PATH` to a logging wrapper. |
|
||||||
|
|
||||||
|
L10–L11 need a `tests/Pest.php` edit — that file is tracked and **outside** the `tests app` reset
|
||||||
|
scope in practice (it lives in `tests/`, so `git checkout -- tests` does restore it; verify with
|
||||||
|
`git status` after).
|
||||||
|
|
||||||
|
For L16/L17, build a throwaway non-git project — and note the trap that burned phase two: a
|
||||||
|
**symlinked** `vendor` makes Pest resolve the project root back to the playground (identical baseline
|
||||||
|
hash), silently invalidating the test. Use a hardlinked copy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NG="$SP/nogit"; rm -rf "$NG"; mkdir -p "$NG"
|
||||||
|
cp -R composer.json composer.lock phpunit.xml artisan tests app bootstrap config routes resources storage "$NG/"
|
||||||
|
[ -f .env ] && cp .env "$NG/"
|
||||||
|
cp -Rl vendor "$NG/vendor" || cp -R vendor "$NG/vendor"
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the baseline path differs (`nogit-<hash>`, not `laravel-4a455a95622ac0ec`), and delete both
|
||||||
|
the temp project and its `~/.pest/tia/nogit-*` dir afterwards.
|
||||||
|
|
||||||
|
### 4.5 Re-run the phase-two matrix (A–K, 156 rows)
|
||||||
|
|
||||||
|
Re-run every row of `PLAN_PHASE_TWO.md` Part 2 against the fixed build. No row's status is trusted
|
||||||
|
until re-measured — the fix touches `Graph`'s read path, which nearly every row exercises. Sections
|
||||||
|
**A, B, H, I** are the load-bearing ones here (H is branch-key resolution; I is filtered mode; both
|
||||||
|
consume the changed methods directly). **H6 and H7 must be re-derived from a cold graph** (Part 1.4).
|
||||||
|
|
||||||
|
Most rows batch cheaply — phase two ran C1–C20 in one call at roughly one line of output each. Use
|
||||||
|
`oneline.php` for the sweep and `cmp.php` only to drill into anomalies.
|
||||||
|
|
||||||
|
### 4.6 Known pre-existing failures — do not report as regressions
|
||||||
|
|
||||||
|
| item | status |
|
||||||
|
|---|---|
|
||||||
|
| **G4 / G4b** — parallel replay clobbers cached `time` on all non-executed tests. `mergeWorkerReplayPartials()` takes `$result['time']` verbatim at `Tia.php:1451`, never routing through `resultTime()` as the sequential sites (1681, 1762) do. Assertions survive because workers replay those themselves. | Pre-existing (pre-fix had no preservation at all), **out of scope for phase three**. Report it as still-present; do not fix it unless Nuno asks. |
|
||||||
|
| **C19** — `--tia --uses=…` cannot be fixtured. TIA hard-errors on PHPUnit classes (`EnsureTiaIsRunningPestTestsOnly`), and Pest has no chainable `->uses()`. | **Expected behaviour per Nuno.** Verify the tier (`w=0`, RO, notice) and move on. Not a defect. |
|
||||||
|
| **J11** — `--repeat` is not a Pest option (`Unknown option "--repeat"`). | **Don't care per Nuno.** Mark SKIP. |
|
||||||
|
| **J10** — `--random-order-seed` alone exits 1 with a WARN. Identical without `--tia`. | Pre-existing Pest behaviour, unrelated. Tier still holds. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — Reporting
|
||||||
|
|
||||||
|
Per row: **tier respected (yes/no)**, the **graph delta** under sentinel patching, and for any failure
|
||||||
|
the **pre-fix contrast** so a regression is told apart from a pre-existing defect. For a pre-fix
|
||||||
|
contrast you need `db70017c` (or `bfd5b756` for "before phase three") files swapped into the
|
||||||
|
playground's `vendor/` — that is a sync, so **ask Nuno first** and always restore afterwards.
|
||||||
|
|
||||||
|
State which pest commit produced the numbers. Close with:
|
||||||
|
|
||||||
|
1. Whether L1–L18 all pass (the fix works).
|
||||||
|
2. Whether A–K regressed anywhere relative to phase two's 154/156.
|
||||||
|
3. The D1–D4 decisions as implemented.
|
||||||
|
4. Anything still open — including G4, which will still be failing.
|
||||||
|
|
||||||
|
Leave the playground on `master` with only the four user-modified files dirty, no stray branches, and
|
||||||
|
no leftover `~/.pest/tia/*` dirs beyond `laravel-4a455a95622ac0ec`.
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
# TIA write-tier conformance — phase two
|
||||||
|
|
||||||
|
## Your task
|
||||||
|
|
||||||
|
**Re-run all 156 rows of the matrix in Part 2 from scratch, against the playground app** at
|
||||||
|
`/Users/nunomaduro/Work/projects/playground/laravel`. Every row, including the ones already marked
|
||||||
|
VERIFIED or PASS — the point of phase two is that no row's status is trusted until it has been
|
||||||
|
re-measured against the current code. The `Phase 1` column is prior evidence and a hint at what to
|
||||||
|
watch, never a reason to skip a row.
|
||||||
|
|
||||||
|
These are **not** the `pestphp/pest` repo's own tests (`composer test`) — do not run those. Each row is
|
||||||
|
a `pest` invocation against the playground's suite, followed by a diff of the TIA graph it wrote.
|
||||||
|
|
||||||
|
Report, per row: tier respected (yes/no), the graph delta under sentinel patching, and — for any
|
||||||
|
failure — the pre-fix contrast so a regression is told apart from a pre-existing defect. Do **not**
|
||||||
|
stop at reading this file or summarising it; the deliverable is executed results.
|
||||||
|
|
||||||
|
Work in this order: **Part 1 (environment traps) → Part 1b (build the fixtures — the playground has
|
||||||
|
none of them) → Part 2 (the matrix) → Part 3 (priorities)**. The matrix is too large for one context;
|
||||||
|
take it one lettered section at a time and report as you finish each. Sections A, B, G, I and K are
|
||||||
|
the load-bearing ones — do those first if you run short.
|
||||||
|
|
||||||
|
Phase one implemented `PLAN.md` Part 1 items 1–4 plus three §5 items. This file turns the matrix into
|
||||||
|
a conformance check rather than a bug list.
|
||||||
|
|
||||||
|
Code under test: `pestphp/pest` at `/Users/nunomaduro/Work/projects/pestphp/pest`, branch
|
||||||
|
`fix/tia-filtered`, commit **`bfd5b756`** or later. Verify with
|
||||||
|
`grep -c 'recordsEdgesInWorkers\|recordsEdges' src/Plugins/Tia.php` → at least 3 hits. Pre-fix
|
||||||
|
baseline for every contrast is **`db70017c`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 0 — What phase one changed
|
||||||
|
|
||||||
|
| # | Change | Files |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `hasUnlocatedTestsToRerun()` stats the file, so a deleted test file is "unlocated" | `src/Plugins/Tia/Graph.php` |
|
||||||
|
| 2 | `enterReplayMode()` uses `activateLinkTracking()` under piggyback coverage | `src/Plugins/Tia.php` |
|
||||||
|
| 3 | `enterReplayMode()` stamps `TIA_PIGGYBACK_COVERAGE` for workers | `src/Plugins/Tia.php` |
|
||||||
|
| 4 | `replaceEdges(…, keepExisting:)` — piggyback edges seed empty sets, never overwrite populated ones | `Graph.php`, `Tia.php` |
|
||||||
|
| 5 | `renderFreshGraph()` stops claiming "fresh graph" when the graph is kept; reason reworded to `recording a coverage baseline` | `Tia.php` |
|
||||||
|
| 6 | `COVERAGE_REPORT_FLAGS` + `coverageReportActive()` union over `originalArguments`; new `pestCoverageActive()` keeps the coverage-cache marker/hijack on Pest's own `--coverage` | `Tia.php` |
|
||||||
|
| 7 | `Tia::recordsEdgesInWorkers()` + `WrapperRunner::handleTia()` inject `-d pcov.directory=<root>` into worker argv | `Tia.php`, `src/Plugins/Parallel/Paratest/WrapperRunner.php` |
|
||||||
|
| 8 | Sequential record runs announce structural drift via `renderFreshGraph()` | `Tia.php` |
|
||||||
|
| 9 | `Graph::getTime()` + `cachedTimeByTestId` + `resultTime()` preserve replayed durations; edge-less write guard is now `$recordsEdges = $complete && ($markKnownTestFiles \|\| $this->recordingActive)` | `Graph.php`, `Tia.php` |
|
||||||
|
|
||||||
|
Deliberately **not** done: `PLAN.md` §5 SIGINT propagation, §5 warning/deprecation `status=0`
|
||||||
|
mapping, and all of §6 (playground annotation fixtures). The vacuous C rows below stay vacuous.
|
||||||
|
|
||||||
|
### Target-outcome changes this forces
|
||||||
|
|
||||||
|
Two rows in the original matrix asserted the **old**, buggy behaviour. Their targets are updated
|
||||||
|
below — do not report them as regressions:
|
||||||
|
|
||||||
|
- **B10** was "`time` differs; statuses stable". Change 9 means replayed entries now **keep** their
|
||||||
|
recorded `time`. New target: `time` differs only for tests that actually executed.
|
||||||
|
- **B12** gains an edge-preservation assertion it never had (see K1).
|
||||||
|
|
||||||
|
### One known-failing repo test
|
||||||
|
|
||||||
|
`tests/Unit/Plugins/Tia/Graph.php:69-76` asserts `hasUnlocatedTestsToRerun('main')` is `false` for
|
||||||
|
`tests/Feature/FooTest.php` under `new Graph(sys_get_temp_dir())` — a path that does not exist. That
|
||||||
|
assertion encodes the I9 bug and **will fail** under change 1. It needs re-pointing at a root/file
|
||||||
|
that exists (e.g. `dirname(__DIR__, 4)` + `'tests/Unit/Plugins/Tia/Graph.php'`). Left untouched by
|
||||||
|
request; it is a repo-test matter, not a playground one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Environment, and the traps in it
|
||||||
|
|
||||||
|
**Playground:** `/Users/nunomaduro/Work/projects/playground/laravel`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/nunomaduro/Work/projects/playground/laravel
|
||||||
|
GRAPH="$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)/graph.json" # ~/.pest/tia/laravel-4a455a95622ac0ec
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Every** pest invocation needs `PAO_DISABLE=1` — the app has `laravel/pao`, which emits JSON
|
||||||
|
when it detects an agent.
|
||||||
|
2. **Never run `git checkout .` or `git checkout -- composer.lock` in the playground.** It has four
|
||||||
|
pre-existing user-modified files — `AGENTS.md`, `CLAUDE.md`, `composer.json`, `composer.lock` —
|
||||||
|
that a blanket reset would destroy. `PLAN.md`'s "reset ritual" is unsafe as written. Scope resets
|
||||||
|
to what you touched: `git checkout -- tests app` / `git clean -fd tests app`, and for
|
||||||
|
`composer.lock` (needed for the drift rows) **copy it aside and copy it back**, verifying with
|
||||||
|
`shasum`.
|
||||||
|
3. **`vendor/pestphp/pest` is a dist copy, not a symlink.** Composer installed
|
||||||
|
`dev-fix/tia-filtered as 5.2.0`, so edits in the pest repo do **not** reach the playground.
|
||||||
|
|
||||||
|
**Tell Nuno whenever a sync is needed to move forward — do not sync silently.** Say what is stale
|
||||||
|
and what the sync would be, then wait. This includes temporarily swapping in `db70017c` files for a
|
||||||
|
before/after contrast. When you report any playground result, state which commit produced it.
|
||||||
|
The sync itself, once he agrees:
|
||||||
|
```bash
|
||||||
|
PEST=/Users/nunomaduro/Work/projects/pestphp/pest
|
||||||
|
V=/Users/nunomaduro/Work/projects/playground/laravel/vendor/pestphp/pest
|
||||||
|
for f in src/Plugins/Tia.php src/Plugins/Tia/Graph.php \
|
||||||
|
src/Plugins/Parallel/Paratest/WrapperRunner.php; do cp "$PEST/$f" "$V/$f"; done
|
||||||
|
```
|
||||||
|
Verify with `grep -c recordsEdgesInWorkers "$V/src/Plugins/Tia.php"` → `1`. **Always restore the
|
||||||
|
current version before continuing** after a pre-fix contrast.
|
||||||
|
4. **Coverage driver:** pcov only, no xdebug. `ini_get('pcov.directory')` is `''` by default — that
|
||||||
|
emptiness is the entire mechanism behind G12.
|
||||||
|
5. **Suite shape as found:** 7 tests in 5 files — `tests/Unit/{CalculatorTest,ExampleTest,GreeterTest,
|
||||||
|
TrioTest}.php`, `tests/Feature/ExampleTest.php`. A healthy sequential graph is **`files=22`,
|
||||||
|
5 edge keys, self-edge on every test** (`PLAN.md`'s `files=25` is stale). Pre-fix parallel gives
|
||||||
|
`files=4` with zero self-edges. **These numbers shift the moment you add the Part 1b fixtures** —
|
||||||
|
re-derive them once, after the fixtures land, and use the new numbers throughout. The invariants
|
||||||
|
that do *not* shift: sequential and parallel must agree, and every test must have a self-edge.
|
||||||
|
6. **Sentinel patching is the only reliable discriminator** between "wrote identical values" and
|
||||||
|
"wrote nothing": rewrite every cached result to `time=9.999 assertions=42`, snapshot, run the
|
||||||
|
case, diff. A canary test absent from `edges` is unreliable under `--filter` because it never
|
||||||
|
matches the filter and so never runs.
|
||||||
|
7. Seeding a cached failure needs `--fresh` (or an env-driven flaky fixture): `--tia` on a clean
|
||||||
|
green tree replays rather than executes, so it can never cache a failure. Working recipe — break
|
||||||
|
an assertion, `pest --tia --fresh`, then restore the source.
|
||||||
|
8. Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||||
|
9. The shell is zsh — build commands with arrays or `eval`; unquoted `$args` does not word-split.
|
||||||
|
|
||||||
|
### Graph summariser
|
||||||
|
|
||||||
|
Write this to a scratch path and use it for every diff.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php // summarise.php <graph.json> [label]
|
||||||
|
$g = json_decode((string) file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$files = $g['files'] ?? []; $edges = $g['edges'] ?? [];
|
||||||
|
echo ($argv[2] ?? $argv[1])."\n files=".count($files).' edges keys='.count($edges)."\n";
|
||||||
|
$dirs = []; foreach ($files as $f) { $dirs[explode('/', (string) $f)[0]] = true; }
|
||||||
|
ksort($dirs); echo ' file dirs: '.implode(', ', array_keys($dirs))."\n";
|
||||||
|
ksort($edges);
|
||||||
|
foreach ($edges as $test => $ids) {
|
||||||
|
$self = 'no';
|
||||||
|
foreach ((array) $ids as $id) { if (($files[$id] ?? null) === $test) { $self = 'YES'; break; } }
|
||||||
|
echo sprintf(" %-42s n=%-3d self=%s\n", $test, count((array) $ids), $self);
|
||||||
|
}
|
||||||
|
foreach ($g['baselines'] ?? [] as $branch => $b) {
|
||||||
|
$r = $b['results'] ?? [];
|
||||||
|
echo " baseline[$branch]: n=".count($r).' sha='.substr((string) ($b['sha'] ?? '-'), 0, 7)."\n";
|
||||||
|
foreach ($r as $id => $x) {
|
||||||
|
echo sprintf(" %-58s status=%d time=%s asserts=%d file=%s\n", substr((string) $id, -58),
|
||||||
|
$x['status'], $x['time'], $x['assertions'], $x['file'] ?? '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Normalised edge-set equality (for G12 / I5 / K1):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php // edgediff.php <a.json> <b.json>
|
||||||
|
function edges(string $p): array {
|
||||||
|
$g = json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR); $out = [];
|
||||||
|
foreach ($g['edges'] as $t => $ids) { $s = array_map(fn ($i) => $g['files'][$i], $ids); sort($s); $out[$t] = $s; }
|
||||||
|
ksort($out); return $out;
|
||||||
|
}
|
||||||
|
$a = edges($argv[1]); $b = edges($argv[2]);
|
||||||
|
echo $a === $b ? "IDENTICAL edge sets\n" : "DIFFER\n";
|
||||||
|
foreach ($a as $t => $s) {
|
||||||
|
$m = array_diff($s, $b[$t] ?? []); $e = array_diff($b[$t] ?? [], $s);
|
||||||
|
if ($m || $e) printf(" %s: -%d +%d\n", $t, count($m), count($e));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-case loop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -- tests app 2>/dev/null; git clean -qfd tests app
|
||||||
|
rm -rf "$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)"
|
||||||
|
PAO_DISABLE=1 ./vendor/bin/pest --tia >/dev/null 2>&1 # seed a healthy graph
|
||||||
|
# sentinel-patch every result to time=9.999 assertions=42, snapshot, run the case, diff
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1b — Fixtures you must build first
|
||||||
|
|
||||||
|
**The playground has none of the fixtures the matrix depends on.** Verified inventory: the only test
|
||||||
|
files are `tests/Unit/{CalculatorTest,ExampleTest,GreeterTest,TrioTest}.php` and
|
||||||
|
`tests/Feature/ExampleTest.php` — 7 plain tests, zero occurrences of `->group()`, `->only()`,
|
||||||
|
`->skip()`, `->todo()`, `->note()`, `->flaky()`, `->covers()`, `->uses()`, `->issue()`, `->pr()`,
|
||||||
|
`->ticket()`, `->assignee()`, datasets, or any `FLAKY_OK`-style env hook. `phpunit.xml` defines only
|
||||||
|
the `Unit` and `Feature` testsuites.
|
||||||
|
|
||||||
|
So roughly 30 rows below cannot run as written until you create the fixtures. Build them **all up
|
||||||
|
front, in one commit**, then re-derive the baseline graph shape once and use those numbers for the
|
||||||
|
whole sweep — every fixture you add changes `files`, the `edges` key count and `n`, so adding them
|
||||||
|
piecemeal invalidates earlier rows.
|
||||||
|
|
||||||
|
| Fixture to create | Rows that need it |
|
||||||
|
|---|---|
|
||||||
|
| a test with `->group('smoke')` | C7, C8, C9, C10 |
|
||||||
|
| an env-driven flaky test (passes iff `FLAKY_OK=1`) | E1, E2, E3, E4 |
|
||||||
|
| a `->skip()`ed test | E9, D8 |
|
||||||
|
| a `->todo()` test | E10, C23, C39 |
|
||||||
|
| a risky test (no assertions; pair with `--disallow-test-output`) | E11, D7 |
|
||||||
|
| a test that triggers a PHPUnit warning | D6 |
|
||||||
|
| a test calling `markTestIncomplete()` | D9 |
|
||||||
|
| a test that triggers a deprecation | D10 |
|
||||||
|
| a dataset test with ≥3 rows | E13, E14 |
|
||||||
|
| `->only()` — added and removed per case, not left in | C31, C32, G7 |
|
||||||
|
| `->covers(App\Services\Calculator::class)` | C18 |
|
||||||
|
| `->uses(...)` / `UsesClass` annotation | C19 |
|
||||||
|
| `->note(...)` | C24 |
|
||||||
|
| `->flaky()` **annotation** (distinct from the env-driven flaky test above) | C25 |
|
||||||
|
| `->issue(123)`, `->pr(1)`, `->ticket('X')`, `->assignee('X')` | C26, C27, C28, C37, C38 |
|
||||||
|
|
||||||
|
This is `PLAN.md` §6's "test-harness gaps to close before re-running", now itemised: without these,
|
||||||
|
the listed rows' graph-invariant assertions match zero tests and **prove nothing** — they pass
|
||||||
|
vacuously. Any row still marked "(vacuous)" in Part 2 is vacuous *only because* its fixture is
|
||||||
|
missing; once you add the fixture, treat the row as unverified and make it load-bearing.
|
||||||
|
|
||||||
|
Rows needing an *action* rather than a fixture — breaking `trio one` for the D rows, an uncommitted
|
||||||
|
test edit for `--dirty`, branch renames and a non-git dir for H5–H10, `--mutate` against
|
||||||
|
`app/Services` for the F rows — are fine as written; `pestphp/pest-plugin-mutate` is installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Full case matrix
|
||||||
|
|
||||||
|
Tiers: **COMPLETE** may change everything · **RESULTS-ONLY** (RO) may change only
|
||||||
|
`baselines[<branch>].results` for tests that ran, and must never remove an entry, add a result for a
|
||||||
|
test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||||
|
**HARD-SUPPRESSED** may change nothing.
|
||||||
|
|
||||||
|
Phase-1 column: **VERIFIED** = re-run against the fixed code in the phase-one session, pre-fix
|
||||||
|
contrast captured · **PASS (sweep)** = passed in the original `db70017` sweep and *not* re-checked
|
||||||
|
since the changes — these are the bulk of phase two's work · **SKIP** = not runnable as written.
|
||||||
|
|
||||||
|
### A — Setup & sanity
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | `composer show pestphp/pest` | version `dev-fix/tia-filtered` | PASS (sweep) |
|
||||||
|
| A2 | `pest --baseline` | prints an existing dir; exit 0 | VERIFIED |
|
||||||
|
| A3 | delete graph, `pest --tia` | graph.json created; one `edges` key per test file; one result per test | VERIFIED |
|
||||||
|
| A4 | `git status` after reset ritual | clean *except the four user-modified files* (see trap 2) | VERIFIED |
|
||||||
|
| A5 | `extension_loaded("pcov")` | `true` | VERIFIED |
|
||||||
|
| A6 | delete graph, plain `pest` | no graph created | PASS (sweep) |
|
||||||
|
| A7 | delete graph, `pest --filter=adds` | no graph created | PASS (sweep) |
|
||||||
|
| A8 | two consecutive `pest --tia` | second replays everything; `sha` unchanged | VERIFIED |
|
||||||
|
|
||||||
|
### B — COMPLETE runs still write
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 | `pest --tia` (clean) | replays; graph written; `sha` = `git rev-parse HEAD` | VERIFIED |
|
||||||
|
| B2 | `pest --no-tia` | results refreshed; prune applied; `sha`/`tree`/`edges` unchanged | PASS (sweep) — recheck under change 9 |
|
||||||
|
| B3 | `pest` (plain) | same as B2 | PASS (sweep) — recheck under change 9 |
|
||||||
|
| B4 | `pest --tia --fresh` | graph purged and rebuilt; `edges` rebuilt; `files` repopulated | VERIFIED |
|
||||||
|
| B5 | `pest --bail`, all green | COMPLETE — graph written, prune applied | PASS (sweep) |
|
||||||
|
| B6 | edit `Calculator.php`, `pest --tia` | `CalculatorTest` re-runs, others replay; `tree` updated | PASS (sweep) |
|
||||||
|
| B7 | delete `trio three`, `pest --tia` | its result pruned; `trio one`/`two` remain | PASS (sweep) |
|
||||||
|
| B8 | delete `GreeterTest.php`, `pest --tia` | result and edges survive (missing-file prune is `--fresh`-only) | PASS (sweep) |
|
||||||
|
| B9 | B8 then `pest --tia --fresh` | entry gone; `files` drops `Greeter.php` too | PASS (sweep) |
|
||||||
|
| B10 | `pest --tia` twice | **UPDATED TARGET:** `time` differs only for tests that executed; replayed entries keep their recorded `time`; statuses stable | VERIFIED |
|
||||||
|
| B11 | add a test file, `pest --tia` | new `edges` key **and** new result appear in the **same** run | VERIFIED (regression guard for change 9) |
|
||||||
|
| B12 | `pest --tia --coverage` | completes; graph written; coverage report prints | VERIFIED |
|
||||||
|
|
||||||
|
### C — Selection narrowing → RESULTS-ONLY
|
||||||
|
|
||||||
|
All rows: RO invariants hold. "Notice" = `TIA does not apply to partial runs — running the selected tests directly.`
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | `pest --filter="adds numbers"` | RO; no notice | PASS (sweep) |
|
||||||
|
| C2 | `pest --tia --filter="adds numbers"` | RO; notice | PASS (sweep) |
|
||||||
|
| C3 | `pest --filter="trio one"` | RO; `trio two`/`three` byte-identical | PASS (sweep) |
|
||||||
|
| C4 | `pest --filter="trio"` | RO; all three update; nothing else does | PASS (sweep) |
|
||||||
|
| C5 | `pest --exclude-filter="Feature"` | RO; no notice; no prune | PASS (sweep) |
|
||||||
|
| C6 | `pest --tia --exclude-filter="Feature"` | RO; notice | PASS (sweep) |
|
||||||
|
| C7 | `pest --group=smoke` | RO; no notice | PASS (sweep) |
|
||||||
|
| C8 | `pest --tia --group=smoke` | RO; notice | PASS (sweep) |
|
||||||
|
| C9 | `pest --exclude-group=smoke` | RO; no notice; no prune | PASS (sweep) |
|
||||||
|
| C10 | `pest --tia --exclude-group=smoke` | RO; notice | PASS (sweep) |
|
||||||
|
| C11 | `pest tests/Unit` | RO; no notice; Feature entry not pruned | PASS (sweep) |
|
||||||
|
| C12 | `pest --tia tests/Unit` | RO; notice | PASS (sweep) |
|
||||||
|
| C13 | `pest tests/Unit/TrioTest.php` | RO; no notice | PASS (sweep) |
|
||||||
|
| C14 | `pest --tia tests/Unit/TrioTest.php` | RO; notice | PASS (sweep) |
|
||||||
|
| C15 | `pest --testsuite=Unit` | RO; no notice | PASS (sweep) |
|
||||||
|
| C16 | `pest --tia --testsuite=Unit` | RO; notice | PASS (sweep) |
|
||||||
|
| C17 | `pest --tia --exclude-testsuite=Feature` | RO; notice | PASS (sweep) |
|
||||||
|
| C18 | `pest --tia --covers='App\Services\Calculator'` | RO; notice | PASS (sweep) |
|
||||||
|
| C19 | `pest --tia --uses='App\Services\Calculator'` | RO; notice | PASS (vacuous — needs fixture) |
|
||||||
|
| C20 | `pest --tia --test-suffix=Test.php` | RO even though all tests run; `edges`/`files`/`n` unchanged | PASS (sweep) |
|
||||||
|
| C21 | `pest --dirty` with an uncommitted test edit | RO; no notice | PASS (sweep) |
|
||||||
|
| C22 | `pest --tia --dirty` | RO; notice | PASS (sweep) |
|
||||||
|
| C23 | `pest --tia --todos` | RO; notice | PASS (vacuous) |
|
||||||
|
| C24 | `pest --tia --notes` | RO; notice | PASS (vacuous) |
|
||||||
|
| C25 | `pest --tia --flaky` | RO; notice | PASS (vacuous) |
|
||||||
|
| C26 | `pest --tia --issue=123` | RO; notice | PASS (vacuous) |
|
||||||
|
| C27 | `pest --tia --pr=1` | RO; notice | PASS (vacuous) |
|
||||||
|
| C28 | `pest --tia --pull-request=1` | RO; notice | PASS (vacuous) |
|
||||||
|
| C29 | `pest --tia --shard=1/2` | RO; notice; no prune | PASS (sweep) |
|
||||||
|
| C30 | `pest --tia --shard=2/2` | RO; notice; C29+C30 covers the suite; neither prunes | PASS (sweep) |
|
||||||
|
| C31 | `->only()` on `trio one`, `pest --tia` | RO; no notice; siblings untouched | PASS (sweep) |
|
||||||
|
| C32 | `->only()` on `trio one`, plain `pest` | RO; no notice | PASS (sweep) |
|
||||||
|
| C33 | `pest --tia --filtered --filter=adds` | RO; notice; filtered yields to narrowing | PASS (sweep) |
|
||||||
|
| C34 | `PEST_TIA=1 pest --filter=adds` | RO; notice | PASS (sweep) |
|
||||||
|
| C35 | `PEST_TIA_FILTERED=1 pest --filter=adds` | RO; notice (regression guard) | PASS (sweep) |
|
||||||
|
| C36 | `pest --filtered --filter=adds` | RO; notice | PASS (sweep) |
|
||||||
|
| C37 | `pest --tia --ticket=X` | RO; notice. `--ticket` **is** a real option (`src/Plugins/Snapshot.php:83`) — it narrows legitimately | PASS (vacuous) |
|
||||||
|
| C38 | `pest --tia --assignee=X` | RO; notice (`src/Plugins/Snapshot.php:81`) | PASS (vacuous) |
|
||||||
|
| C39 | `pest --tia --todo` | RO; notice; matches nothing | PASS (vacuous) |
|
||||||
|
|
||||||
|
### D — Truncation → RESULTS-ONLY
|
||||||
|
|
||||||
|
D1–D5, D11–D13, D16 precondition: `trio one` broken. D6–D10 run against a green suite so the `--stop-on-*` trigger is the only narrowing.
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | `pest --bail` | RO; `trio one` = `status=7`; siblings survive unchanged | PASS (sweep) |
|
||||||
|
| D2 | `pest --retry` | RO; siblings survive | PASS (sweep) |
|
||||||
|
| D3 | `pest --stop-on-failure` | RO | PASS (sweep) |
|
||||||
|
| D4 | `pest --stop-on-defect` | RO | PASS (sweep) |
|
||||||
|
| D5 | `pest --stop-on-error` (no error occurs) | COMPLETE — nothing stopped it | PASS (sweep) |
|
||||||
|
| D6 | `pest --stop-on-warning` | RO when it fires. Warning stored as `status=0`, not `6` | PASS (sweep) |
|
||||||
|
| D7 | `pest --stop-on-risky --disallow-test-output` | RO; `status=5` | PASS (sweep) |
|
||||||
|
| D8 | `pest --stop-on-skipped` | RO; `status=1` | PASS (sweep) |
|
||||||
|
| D9 | `pest --stop-on-incomplete` | RO; `status=2` | PASS (sweep) |
|
||||||
|
| D10 | `pest --stop-on-deprecation` | RO. Deprecation stored as `status=0`, not `4` | PASS (sweep) |
|
||||||
|
| D11 | `pest --tia --bail` | RO | PASS (sweep) |
|
||||||
|
| D12 | `pest --bail --filter=trio` | RO (narrowed *and* truncated) | PASS (sweep) |
|
||||||
|
| D13 | `stopOnFailure="true"` in `phpunit.xml`, plain `pest` | RO — caught via `stoppedEarly()`, not flag matching | PASS (sweep) |
|
||||||
|
| D14 | D13 config, green suite | COMPLETE | PASS (sweep) |
|
||||||
|
| D15 | `pest --bail`, last test in run order fails | RO — over-conservative but safe; nothing was skipped | PASS (sweep) |
|
||||||
|
| D16 | `pest --tia --filtered --bail`, broken affected test | RO | PASS (sweep) |
|
||||||
|
|
||||||
|
### E — Result merge semantics
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E1 | cached `flaky`=7, `FLAKY_OK=1 pest --filter=flaky` | flips to `status=0`; all other results byte-identical | PASS (sweep) |
|
||||||
|
| E2 | cached `flaky`=0, `pest --filter=flaky` (env unset) | flips to `status=7` | PASS (sweep) |
|
||||||
|
| E3 | after E1, `pest --tia --filtered` clean | `No affected tests found`; zero graph delta | PASS (sweep) |
|
||||||
|
| E4 | after E2, `pest --tia --filtered` | re-runs, `from 1 previously unsuccessful test` | PASS (sweep) |
|
||||||
|
| E5 | any partial run | `assertions` and `time` update for the test that ran (it executed, so change 9 does not apply) | PASS (sweep) |
|
||||||
|
| E6 | any partial run | `message` of untouched tests unchanged | PASS (sweep) |
|
||||||
|
| E7 | two sequential partial runs on different tests | each updates only its own entry; both persist | PASS (sweep) |
|
||||||
|
| E8 | `pest --filter="trio one"` | siblings keep exact `status`/`time`/`assertions`/`message` | PASS (sweep) |
|
||||||
|
| E9 | partial run of a `->skip()`ed test | `status=1` | PASS (sweep) |
|
||||||
|
| E10 | partial run of a `->todo()` test | `status=1`, `assertions=0`, `message="__TODO__"` | PASS (sweep) |
|
||||||
|
| E11 | partial run of a risky test | `status=5` | PASS (sweep) |
|
||||||
|
| E12 | any partial run | `fingerprint` byte-identical | PASS (sweep) |
|
||||||
|
| E13 | dataset test, `--filter` matching one row | that row updates; other rows survive (the old prune bug) | PASS (sweep) |
|
||||||
|
| E14 | dataset test, full `--tia` after deleting a row | the deleted row is pruned | PASS (sweep) |
|
||||||
|
|
||||||
|
### F — Guard rails
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| F1 | test absent from `edges`, `pest --filter="brand new"` | no result recorded | VERIFIED |
|
||||||
|
| F2 | complete `pest --tia` first, then the same filter | result is recorded | VERIFIED |
|
||||||
|
| F3 | `pest --mutate --path=app/Services --covered-only` | statuses/`edges`/`sha`/`tree`/`files` identical; only `time` may change | PASS (sweep) |
|
||||||
|
| F4 | `pest --mutate --parallel --path=… --covered-only` | same (observed: zero delta) | PASS (sweep) — recheck under change 7 |
|
||||||
|
| F5 | weakened test so a mutant survives, `pest --mutate` | no status change in the baseline | PASS (sweep) |
|
||||||
|
| F6 | `pest --mutate` with the graph deleted | no graph created | PASS (sweep) |
|
||||||
|
| F7 | mutation subprocess env | carries `PEST_MUTATION_TESTING`; `--mutate` stripped from argv | PASS (sweep) |
|
||||||
|
| F8 | grep baseline after `--mutate` | no mutation-flavoured messages | PASS (sweep) |
|
||||||
|
|
||||||
|
### G — Parallel
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| G1 | `pest --tia --parallel` (clean) | COMPLETE — graph written; all 7 results present | VERIFIED |
|
||||||
|
| G2 | `pest --tia --parallel --filter=adds` | RO | PASS (sweep) |
|
||||||
|
| G3 | `pest --parallel --bail`, `trio one` broken | RO; siblings survive | PASS (sweep) |
|
||||||
|
| G4 | `pest --tia --parallel --bail` | RO | PASS (sweep) |
|
||||||
|
| G5 | `pest --tia --filtered --parallel` after a source edit | narrows to affected; writes | VERIFIED (see I5) |
|
||||||
|
| G6 | `pest --tia --parallel` | worker results reach the parent baseline | VERIFIED |
|
||||||
|
| G7 | `->only()` + `pest --tia --parallel` | RO | PASS (sweep) |
|
||||||
|
| G8 | `pest --tia --parallel --shard=1/2` | RO | PASS (sweep) |
|
||||||
|
| G9 | broken test in one worker, `pest --parallel --bail` | whole run is RO, not just that worker's slice | PASS (sweep) |
|
||||||
|
| G10 | `pest --parallel --retry` | `InvalidOption`; graph untouched | PASS (sweep) |
|
||||||
|
| G11 | `pest --tia --parallel --fresh` | graph rebuilt | VERIFIED |
|
||||||
|
| G12 | `edges` after `pest --tia` vs `pest --tia --parallel --fresh` | **equivalent edge sets** — `files=22`, self-edge on all 5 tests, both | **VERIFIED (fixed)** — pre-fix: `files=4`, 0 self-edges |
|
||||||
|
|
||||||
|
### H — Baseline key / branch resolution
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| H1 | `pest --tia` on `master` | only a `master` key | VERIFIED |
|
||||||
|
| H2 | plain `pest` on `master` | writes `master`, not `main` | PASS (sweep) |
|
||||||
|
| H3 | `pest --bail` truncated on `master` | no `main` key minted | PASS (sweep) |
|
||||||
|
| H4 | `pest --filter=adds` on `master` | no `main` key minted | PASS (sweep) |
|
||||||
|
| H5 | `git branch -m main`, full `pest --tia` | single `main` key | PASS (sweep) |
|
||||||
|
| H6 | `git checkout -b feature/x`, `pest --tia` | `feature/x` key appears; reads fall back to the existing baseline | PASS (sweep) |
|
||||||
|
| H7 | detached HEAD, `pest --tia` | falls back to the `main` key for both reads and writes; no `HEAD` key | PASS (sweep) |
|
||||||
|
| H8 | after every C and D case | no baseline key other than the real branch | PASS (sweep) |
|
||||||
|
| H9 | non-git dir, `pest --tia` | `MissingDependency` — `The feature "Tia mode" requires "git".` | PASS (sweep) |
|
||||||
|
| H10 | non-git dir, plain `pest` | runs normally; no baseline dir created | PASS (sweep) |
|
||||||
|
|
||||||
|
### I — Filtered mode
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| I1 | clean + green, `pest --tia --filtered` | `No affected tests found`; zero graph delta | PASS (sweep) |
|
||||||
|
| I2 | edit `Calculator.php` | only `CalculatorTest` runs | VERIFIED |
|
||||||
|
| I3 | cached failure, clean tree | re-runs it, `from 1 previously unsuccessful test` | PASS (sweep) |
|
||||||
|
| I4 | I2 with sentinels | `tree` updated; exactly one entry rewritten; all others retained | PASS (sweep) |
|
||||||
|
| I5 | `pest --tia --filtered --parallel` | same narrowing as I2 **and `edges` unchanged** | **VERIFIED (fixed)** — 1 affected test, edges identical |
|
||||||
|
| I6 | `PEST_TIA_FILTERED=1 pest --tia` | identical delta to I4 | PASS (sweep) |
|
||||||
|
| I7 | `pest --tia --filtered tests/Unit` | explicit path wins; filtered off; RO; notice | PASS (sweep) |
|
||||||
|
| I8 | `pest --tia --filtered --coverage-text` | filtered mode disabled by an active coverage report → full suite runs | **VERIFIED (fixed)** — pre-fix: `No affected tests found` |
|
||||||
|
| I9 | cached failure whose test file was deleted | WARN `Some cached tests due a re-run could not be located on disk` + `Running the full suite with replay instead of a filtered run` | **VERIFIED (fixed)** — pre-fix: `No tests found`, exit 0 green |
|
||||||
|
| I10 | `pest --tia --filtered` with no baseline yet | records a baseline instead of filtering | PASS (sweep) |
|
||||||
|
| I11 | edit a Blade view a Feature test renders | the Feature test is selected | PASS (sweep) |
|
||||||
|
| I12 | edit `composer.lock` | fingerprint drift → full rebuild, **and the reason is printed sequentially**: `fresh graph (composer.lock changed)` | **VERIFIED (fixed)** — pre-fix: bare `Running in TIA mode.` |
|
||||||
|
|
||||||
|
### J — Interactions & regressions
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| J1 | `pest --tia --fresh --filter=adds` | graph **not** purged; RO | PASS (sweep) |
|
||||||
|
| J2 | `pest --tia --refetch --filter=adds` | refetch not performed; RO | PASS (sweep) |
|
||||||
|
| J3 | `pest --no-tia --filter=adds` | RO — narrowing wins over `--no-tia`; no notice | PASS (sweep) |
|
||||||
|
| J4 | `pest --tia --no-tia` | TIA disabled; COMPLETE | PASS (sweep) |
|
||||||
|
| J5 | `<groups><exclude><group>integration</group></exclude></groups>`, `pest --tia` | COMPLETE, no notice — an always-in-force filter applies to baseline runs too | PASS (sweep) |
|
||||||
|
| J6 | `pest --tia --compact` | COMPLETE | PASS (sweep) |
|
||||||
|
| J7 | `pest --tia -v` | COMPLETE | PASS (sweep) |
|
||||||
|
| J8 | `pest --tia --profile` | COMPLETE | PASS (sweep) |
|
||||||
|
| J9 | `pest --tia --order-by=random` | COMPLETE | PASS (sweep) |
|
||||||
|
| J10 | `pest --tia --random-order-seed=1234` | COMPLETE | PASS (sweep) |
|
||||||
|
| J11 | `pest --tia --repeat=2` | COMPLETE — repetition is not narrowing | **SKIP** — `--repeat` is not a Pest option |
|
||||||
|
| J12 | `pest --filter=adds` twice | only the executed test's `time` differs; rest byte-identical | PASS (sweep) |
|
||||||
|
| J13 | `pest --tia --min=50` | COMPLETE (silent no-op without `--coverage`) | PASS (sweep) |
|
||||||
|
| J14 | SIGINT mid-suite | RO — an interrupted run is truncated | PASS (sweep) — signal still does not reach the re-exec'd child (not fixed) |
|
||||||
|
| J15 | delete the graph, `pest --tia --filtered` | records a baseline rather than erroring | PASS (sweep) |
|
||||||
|
| J16 | corrupt `graph.json`, `pest --tia` | recovers by rebuilding; no crash | PASS (sweep) |
|
||||||
|
|
||||||
|
### K — New rows for the phase-one fixes
|
||||||
|
|
||||||
|
These assert behaviour no original row covered. All were verified in phase one; re-run them as
|
||||||
|
regression guards.
|
||||||
|
|
||||||
|
| # | Case | Target outcome | Phase 1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| K1 | healthy `pest --tia` graph, then `pest --tia --coverage` | `edges` **byte-identical** — piggyback data may seed empty sets, never narrow populated ones; `files=22` stays `22` | **VERIFIED (fixed)** — pre-fix: `ExampleTest` 16→2 edges, self-edges lost |
|
||||||
|
| K2 | same run's headline | `Experimental TIA mode enabled / recording a coverage baseline.` — no false `fresh graph` | **VERIFIED (fixed)** |
|
||||||
|
| K3 | `pest --tia` ×3 on a clean tree | replayed entries keep their recorded `time` across all three | **VERIFIED (fixed)** — pre-fix: `0.053 → 0.001 → 0.001 → 0.001` |
|
||||||
|
| K4 | add a test file, one `pest --tia` (graph exists, fingerprint matches → replay+refresh) | result **and** edges appear in that same run; `n` grows by 1 | **VERIFIED** — regression guard for change 9 |
|
||||||
|
| K5 | add a test file, plain `pest --no-tia` | no result written for it; `n` unchanged; no edges | **VERIFIED (fixed)** — pre-fix: edge-less result inflated `n` |
|
||||||
|
| K6 | `pest --tia --parallel` worker argv | carries `-d pcov.directory=<projectRoot>`; `PEST_TIA` unset; not re-exec'd | **VERIFIED** — probe `bin/worker.php` |
|
||||||
|
| K7 | second `pest --tia --coverage` (cache primed → replay) | recorder uses link tracking only, does not clear PHPUnit's data mid-collection; coverage report intact | Not yet measured — **new work** |
|
||||||
|
| K8 | `pest --tia --parallel --coverage` | workers read `TIA_PIGGYBACK_COVERAGE`; no widened pcov scope; report intact | Not yet measured — **new work** |
|
||||||
|
| K9 | `pest --tia --filtered --coverage-html=<dir>` / `--coverage-clover=<file>` | filtered mode off, same as I8, for every flag in `COVERAGE_REPORT_FLAGS` | Not yet measured — **new work** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Priorities for phase two
|
||||||
|
|
||||||
|
0. **Part 1b fixtures** — nothing in C, D6–D10, or E9–E14 means anything until they exist.
|
||||||
|
1. **K7, K8, K9** — the only rows never measured. K7/K8 exercise changes 2 and 3, which were
|
||||||
|
reasoned about but not observed; K9 covers the seven `COVERAGE_REPORT_FLAGS` beyond
|
||||||
|
`--coverage-text`.
|
||||||
|
2. **B2, B3, F4** and all of **D** and **E** — change 9 touched the shared result-write path, and
|
||||||
|
these are the rows that exercise it hardest. `$recordsEdges` is the thing to falsify: it must be
|
||||||
|
false for every partial and every non-recording run.
|
||||||
|
3. **F3–F8** — change 7 injects a `-d` into worker argv; `--mutate --parallel` is the one place that
|
||||||
|
both spawns workers and must write nothing.
|
||||||
|
4. **H1–H10** — untouched by these changes; cheapest bulk confirmation.
|
||||||
|
5. Raw PHPUnit coverage flags print **no report at all** in Pest, with or without TIA
|
||||||
|
(`--no-tia --coverage-text` is equally silent). Pre-existing, unrelated to change 6 — do not
|
||||||
|
chase it as a regression, but it means I8/K9 can only assert the selection half.
|
||||||
|
|
||||||
|
Report per row: tier respected (yes/no), the graph delta under sentinel patching, and for any
|
||||||
|
failure the pre-fix contrast (write `git show db70017c:<file>` into vendor, re-run, restore) so a
|
||||||
|
regression is told apart from a pre-existing defect.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
|
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (4.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
|
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
|
||||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
||||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
||||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
||||||
@@ -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)**
|
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||||
- Follow the creator Nuno Maduro:
|
- 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)**.
|
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
|
||||||
|
|
||||||
|
|
||||||
### Platinum Sponsors
|
|
||||||
|
|
||||||
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
|
|
||||||
- **[Mailtrap](https://l.rw.rw/pestphp)**
|
|
||||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
|
||||||
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
|
|
||||||
|
|
||||||
### Gold Sponsors
|
|
||||||
|
|
||||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||||
|
- **[PhpStorm](https://jb.gg/nuno)**
|
||||||
### Premium Sponsors
|
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||||
|
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||||
- [Zapiet](https://zapiet.com/?ref=pestphp)
|
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||||
- [Load Forge](https://loadforge.com/?ref=pestphp)
|
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||||
- [Route4Me](https://route4me.com/pt?ref=pestphp)
|
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||||
- [Nerdify](https://getnerdify.com/?ref=pestphp)
|
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||||
- [Akaunting](https://akaunting.com/?ref=pestphp)
|
|
||||||
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
|
|
||||||
|
|
||||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||||
|
|||||||
+3
-3
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
When releasing a new version of Pest there are some checks and updates that need to be done:
|
When releasing a new version of Pest there are some checks and updates that need to be done:
|
||||||
|
|
||||||
> **For Pest v3 you should use the `3.x` branch instead.**
|
> **For Pest v4 you should use the `4.x` branch instead.**
|
||||||
|
|
||||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
|
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
||||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
|
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
|
||||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||||
- Run the tests locally using: `composer test`
|
- Run the tests locally using: `composer test`
|
||||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||||
|
|||||||
+154
-19
@@ -25,26 +25,72 @@ const PAGE_DIR_CANDIDATES = [
|
|||||||
|
|
||||||
async function loadRolldown() {
|
async function loadRolldown() {
|
||||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||||
const path = projectRequire.resolve('rolldown')
|
let path = null
|
||||||
|
|
||||||
|
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||||
|
|
||||||
|
if (path === null) {
|
||||||
|
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||||
|
// dependency of the vite package rather than a top-level install.
|
||||||
|
try {
|
||||||
|
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||||
|
path = viteRequire.resolve('rolldown')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === null) return null
|
||||||
|
|
||||||
return await import(pathToFileURL(path).href)
|
return await import(pathToFileURL(path).href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripJsonComments(raw) {
|
||||||
|
let out = ''
|
||||||
|
let inString = false
|
||||||
|
let quote = ''
|
||||||
|
let inLine = false
|
||||||
|
let inBlock = false
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const c = raw[i]
|
||||||
|
const n = raw[i + 1]
|
||||||
|
|
||||||
|
if (inLine) {
|
||||||
|
if (c === '\n') { inLine = false; out += c }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inBlock) {
|
||||||
|
if (c === '*' && n === '/') { inBlock = false; i++ }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (inString) {
|
||||||
|
out += c
|
||||||
|
if (c === '\\') { out += n ?? ''; i++; continue }
|
||||||
|
if (c === quote) inString = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
|
||||||
|
if (c === '/' && n === '/') { inLine = true; i++; continue }
|
||||||
|
if (c === '/' && n === '*') { inBlock = true; i++; continue }
|
||||||
|
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
|
||||||
|
out += c
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
async function readJsonWithComments(path) {
|
async function readJsonWithComments(path) {
|
||||||
const raw = await readFile(path, 'utf8')
|
const raw = await readFile(path, 'utf8')
|
||||||
const stripped = raw
|
return JSON.parse(stripJsonComments(raw))
|
||||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
||||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
||||||
return JSON.parse(stripped)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAliasFromTsconfig() {
|
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||||
const alias = {}
|
const alias = {}
|
||||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||||
const p = join(PROJECT_ROOT, name)
|
const p = join(projectRoot, name)
|
||||||
if (!existsSync(p)) continue
|
if (!existsSync(p)) continue
|
||||||
let cfg
|
let cfg
|
||||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||||
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
|
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||||
for (const [key, targets] of Object.entries(paths)) {
|
for (const [key, targets] of Object.entries(paths)) {
|
||||||
if (!key.endsWith('/*')) continue
|
if (!key.endsWith('/*')) continue
|
||||||
@@ -58,6 +104,74 @@ async function loadAliasFromTsconfig() {
|
|||||||
return alias
|
return alias
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VITE_CONFIG_FILES = [
|
||||||
|
'vite.config.ts',
|
||||||
|
'vite.config.js',
|
||||||
|
'vite.config.mjs',
|
||||||
|
'vite.config.cjs',
|
||||||
|
'vite.config.mts',
|
||||||
|
'vite.config.cts',
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveAliasTarget(projectRoot, target) {
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
// Vite resolves a leading slash against the project root, unless the
|
||||||
|
// config author used a genuinely absolute path.
|
||||||
|
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(projectRoot, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usesLaravelVitePlugin(projectRoot) {
|
||||||
|
const p = join(projectRoot, 'package.json')
|
||||||
|
if (!existsSync(p)) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
||||||
|
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
||||||
|
const alias = {}
|
||||||
|
let source = null
|
||||||
|
|
||||||
|
for (const name of VITE_CONFIG_FILES) {
|
||||||
|
const p = join(projectRoot, name)
|
||||||
|
if (!existsSync(p)) continue
|
||||||
|
try { source = await readFile(p, 'utf8') } catch { continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== null) {
|
||||||
|
// The config is executable code we cannot import safely, so extract alias
|
||||||
|
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
||||||
|
// the value expression — covers `'@': '/resources/js'` as well as
|
||||||
|
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
||||||
|
// top-level comma; call arguments are kept via the paren group.
|
||||||
|
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
||||||
|
const key = m[1]
|
||||||
|
if (alias[key] !== undefined) continue
|
||||||
|
|
||||||
|
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
||||||
|
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
||||||
|
if (!target || target.includes('*')) continue
|
||||||
|
|
||||||
|
alias[key] = resolveAliasTarget(projectRoot, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// laravel-vite-plugin registers '@' → resources/js by default.
|
||||||
|
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
||||||
|
alias['@'] = resolve(projectRoot, 'resources/js')
|
||||||
|
}
|
||||||
|
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
|
||||||
async function listPageFiles(pagesDir) {
|
async function listPageFiles(pagesDir) {
|
||||||
if (!existsSync(pagesDir)) return []
|
if (!existsSync(pagesDir)) return []
|
||||||
|
|
||||||
@@ -121,8 +235,16 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rolldown } = await loadRolldown()
|
const loaded = await loadRolldown()
|
||||||
const alias = await loadAliasFromTsconfig()
|
|
||||||
|
if (loaded === null) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rolldown } = loaded
|
||||||
|
// The vite config is what the dev server actually resolves with — let it win.
|
||||||
|
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
||||||
const aliasKeys = Object.keys(alias)
|
const aliasKeys = Object.keys(alias)
|
||||||
|
|
||||||
const graph = new Map()
|
const graph = new Map()
|
||||||
@@ -167,7 +289,7 @@ async function main() {
|
|||||||
cwd: PROJECT_ROOT,
|
cwd: PROJECT_ROOT,
|
||||||
resolve: {
|
resolve: {
|
||||||
alias,
|
alias,
|
||||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
|
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||||
},
|
},
|
||||||
transform: { jsx: 'preserve' },
|
transform: { jsx: 'preserve' },
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
@@ -192,6 +314,11 @@ async function main() {
|
|||||||
|
|
||||||
stack.add(id)
|
stack.add(id)
|
||||||
const acc = new Set()
|
const acc = new Set()
|
||||||
|
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
||||||
|
// that dependency's subtree and must not be memoized — the ancestor call
|
||||||
|
// completes it for the current traversal, but a cached copy would leak the
|
||||||
|
// incomplete set into other pages' traversals.
|
||||||
|
let complete = true
|
||||||
const deps = graph.get(id)
|
const deps = graph.get(id)
|
||||||
if (deps) {
|
if (deps) {
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
@@ -200,13 +327,17 @@ async function main() {
|
|||||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||||
acc.add(rel)
|
acc.add(rel)
|
||||||
}
|
}
|
||||||
if (stack.has(dep)) continue
|
if (stack.has(dep)) {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
const child = computeTransitive(dep, stack)
|
const child = computeTransitive(dep, stack)
|
||||||
if (child) for (const r of child) acc.add(r)
|
if (child) for (const r of child) acc.add(r)
|
||||||
|
if (!transitiveCache.has(dep)) complete = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stack.delete(id)
|
stack.delete(id)
|
||||||
transitiveCache.set(id, acc)
|
if (complete) transitiveCache.set(id, acc)
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +361,14 @@ async function main() {
|
|||||||
process.stdout.write(JSON.stringify(payload))
|
process.stdout.write(JSON.stringify(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const invokedDirectly = process.argv[1] !== undefined
|
||||||
void pathToFileURL
|
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||||
await main()
|
|
||||||
} catch (err) {
|
if (invokedDirectly) {
|
||||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
try {
|
||||||
process.exit(1)
|
await main()
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-16
@@ -17,21 +17,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3.0",
|
"php": "^8.4",
|
||||||
"brianium/paratest": "^7.20.0",
|
"brianium/paratest": "^7.23.1",
|
||||||
"composer/xdebug-handler": "^3.0.5",
|
"nunomaduro/collision": "^8.9.5",
|
||||||
"nunomaduro/collision": "^8.9.4",
|
|
||||||
"nunomaduro/termwind": "^2.4.0",
|
"nunomaduro/termwind": "^2.4.0",
|
||||||
"pestphp/pest-plugin": "^4.0.0",
|
"pestphp/pest-plugin": "^5.0.0",
|
||||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||||
"phpunit/phpunit": "^12.5.30",
|
"phpunit/phpunit": "^13.2.6",
|
||||||
"symfony/process": "^7.4.13|^8.1.0"
|
"symfony/process": "^8.1.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"filp/whoops": "<2.18.3",
|
"filp/whoops": "<2.18.3",
|
||||||
"phpunit/phpunit": ">12.5.30",
|
"phpunit/phpunit": ">13.2.6",
|
||||||
"sebastian/exporter": "<7.0.0",
|
"sebastian/exporter": "<7.0.0",
|
||||||
"webmozart/assert": "<1.11.0"
|
"webmozart/assert": "<1.11.0"
|
||||||
},
|
},
|
||||||
@@ -49,6 +48,7 @@
|
|||||||
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
|
||||||
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
|
||||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||||
|
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
|
||||||
"Tests\\": "tests/PHPUnit/"
|
"Tests\\": "tests/PHPUnit/"
|
||||||
},
|
},
|
||||||
"classmap": [
|
"classmap": [
|
||||||
@@ -59,11 +59,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"mrpunyapal/peststan": "^0.2.10",
|
"pestphp/pest-dev-tools": "^5.0.0",
|
||||||
"pestphp/pest-dev-tools": "^4.1.0",
|
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||||
"psy/psysh": "^0.12.23"
|
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||||
|
"psy/psysh": "^0.12.24"
|
||||||
},
|
},
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The MIT License (MIT)
|
||||||
|
*
|
||||||
|
* Copyright (c) 2011 Brian Scaturro
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ParaTest\WrapperRunner;
|
||||||
|
|
||||||
|
use PHPUnit\TextUI\Output\Printer;
|
||||||
|
|
||||||
|
use function preg_match;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||||
|
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||||
|
* instead of being treated as unexpected output.
|
||||||
|
*/
|
||||||
|
final readonly class ProgressPrinterOutput implements Printer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Printer $progressPrinter,
|
||||||
|
private Printer $outputPrinter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function print(string $buffer): void
|
||||||
|
{
|
||||||
|
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||||
|
if (
|
||||||
|
$buffer === "\n"
|
||||||
|
|| preg_match('/^ +$/', $buffer) === 1
|
||||||
|
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($buffer) {
|
||||||
|
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
||||||
|
default => $this->outputPrinter->print($buffer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flush(): void
|
||||||
|
{
|
||||||
|
$this->progressPrinter->flush();
|
||||||
|
$this->outputPrinter->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
|||||||
(static function () use ($suiteClassFile) {
|
(static function () use ($suiteClassFile) {
|
||||||
try {
|
try {
|
||||||
include_once $suiteClassFile;
|
include_once $suiteClassFile;
|
||||||
|
|
||||||
|
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Panic::with($e);
|
Panic::with($e);
|
||||||
}
|
}
|
||||||
|
|
||||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
$loadedClasses = array_values(
|
$loadedClasses = array_values(
|
||||||
|
|||||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
namespace Pest\Logging\TeamCity\Subscriber;
|
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||||
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
use PHPUnit\Event\Test\Skipped;
|
||||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||||
|
*
|
||||||
|
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||||
|
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||||
|
* "T" and swallows the "S" that follows it.
|
||||||
*/
|
*/
|
||||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||||
{
|
{
|
||||||
public function notify(Skipped $event): void
|
public function notify(Skipped $event): void
|
||||||
{
|
{
|
||||||
if (str_contains($event->message(), '__TODO__')) {
|
if ($event->message() === '__TODO__') {
|
||||||
$this->printTodoItem();
|
$this->printTodoItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger()->testSkipped($event);
|
$this->printer()->testSkipped();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
|||||||
private function printTodoItem(): void
|
private function printTodoItem(): void
|
||||||
{
|
{
|
||||||
$mirror = new ReflectionClass($this->printer());
|
$mirror = new ReflectionClass($this->printer());
|
||||||
$printerMirror = $mirror->getMethod('printProgress');
|
$printProgress = $mirror->getMethod('printProgress');
|
||||||
$printerMirror->invoke($this->printer(), 'T');
|
$printProgress->invoke($this->printer(), 'T');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
includes:
|
includes:
|
||||||
- phpstan-baseline.neon
|
- phpstan-baseline.neon
|
||||||
- phpstan-pest-extension.neon
|
- phpstan-pest-extension.neon
|
||||||
- vendor/mrpunyapal/peststan/extension.neon
|
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
level: 7
|
level: 7
|
||||||
|
|||||||
+2
-1
@@ -18,8 +18,9 @@
|
|||||||
<directory suffix=".php">./tests</directory>
|
<directory suffix=".php">./tests</directory>
|
||||||
<directory suffix=".php">./tests-external</directory>
|
<directory suffix=".php">./tests-external</directory>
|
||||||
<exclude>./tests/.snapshots</exclude>
|
<exclude>./tests/.snapshots</exclude>
|
||||||
<exclude>./tests/.tests</exclude>
|
|
||||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||||
|
<exclude>./tests/Fixtures/Suites</exclude>
|
||||||
|
<exclude>./tests/Fixtures/Tia</exclude>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
<source>
|
<source>
|
||||||
|
|||||||
+41
@@ -2,22 +2,63 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||||
|
use Pest\Rector\Set\PestSetList;
|
||||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||||
use Rector\Config\RectorConfig;
|
use Rector\Config\RectorConfig;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
|
||||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
|
||||||
|
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
|
||||||
|
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
|
||||||
|
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
||||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||||
|
|
||||||
return RectorConfig::configure()
|
return RectorConfig::configure()
|
||||||
->withPaths([
|
->withPaths([
|
||||||
__DIR__.'/src',
|
__DIR__.'/src',
|
||||||
|
__DIR__.'/tests',
|
||||||
|
])
|
||||||
|
->withSets([
|
||||||
|
PestSetList::CODING_STYLE,
|
||||||
])
|
])
|
||||||
->withSkip([
|
->withSkip([
|
||||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||||
|
__DIR__.'/tests/Fixtures/Arch',
|
||||||
|
// Fixture suites are pinned by the TeamCity / JUnit snapshots, down to
|
||||||
|
// the line numbers — rewriting their source would break them.
|
||||||
|
__DIR__.'/tests/Fixtures/Suites',
|
||||||
ReturnNeverTypeRector::class,
|
ReturnNeverTypeRector::class,
|
||||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||||
NarrowObjectReturnTypeRector::class,
|
NarrowObjectReturnTypeRector::class,
|
||||||
RemoveParentDelegatingConstructorRector::class,
|
RemoveParentDelegatingConstructorRector::class,
|
||||||
|
RemoveDuplicatedReturnSelfDocblockRector::class,
|
||||||
|
RemoveUselessUnionReturnDocblockRector::class,
|
||||||
|
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
||||||
|
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
||||||
|
],
|
||||||
|
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
||||||
|
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
||||||
|
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
||||||
|
UseToMatchArrayRector::class,
|
||||||
|
// Test fixtures rely on "unused" constructors, params and properties
|
||||||
|
// (resolved via the container or read through reflection), so the
|
||||||
|
// dead-code and return-type rules below must not touch the test suite.
|
||||||
|
RemoveEmptyClassMethodRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
|
RemoveUnusedConstructorParamRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
|
RemoveUnusedPrivatePropertyRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
|
AddArrowFunctionReturnTypeRector::class => [
|
||||||
|
__DIR__.'/tests',
|
||||||
|
],
|
||||||
])
|
])
|
||||||
->withPreparedSets(
|
->withPreparedSets(
|
||||||
deadCode: true,
|
deadCode: true,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
name: pest-testing
|
||||||
|
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
@php
|
||||||
|
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||||
|
@endphp
|
||||||
|
# Pest Testing 5
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating Tests
|
||||||
|
|
||||||
|
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||||
|
|
||||||
|
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||||
|
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||||
|
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||||
|
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||||
|
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||||
|
- Browser tests: `tests/Browser/` directory.
|
||||||
|
- Do NOT remove tests without approval - these are core application code.
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||||
|
|
||||||
|
@boostsnippet("Basic Pest Test Example", "php")
|
||||||
|
it('is true', function () {
|
||||||
|
expect(true)->toBeTrue();
|
||||||
|
});
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||||
|
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||||
|
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||||
|
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||||
|
|
||||||
|
## Assertions
|
||||||
|
|
||||||
|
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||||
|
|
||||||
|
@boostsnippet("Pest Response Assertion", "php")
|
||||||
|
it('returns all', function () {
|
||||||
|
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||||
|
});
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
| Use | Instead of |
|
||||||
|
|-----|------------|
|
||||||
|
| `assertSuccessful()` | `assertStatus(200)` |
|
||||||
|
| `assertNotFound()` | `assertStatus(404)` |
|
||||||
|
| `assertForbidden()` | `assertStatus(403)` |
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||||
|
|
||||||
|
## Datasets
|
||||||
|
|
||||||
|
Use datasets for repetitive tests (validation rules, etc.):
|
||||||
|
|
||||||
|
@boostsnippet("Pest Dataset Example", "php")
|
||||||
|
it('has emails', function (string $email) {
|
||||||
|
expect($email)->not->toBeEmpty();
|
||||||
|
})->with([
|
||||||
|
'james' => 'james@laravel.com',
|
||||||
|
'taylor' => 'taylor@laravel.com',
|
||||||
|
]);
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
## Pest 5 Features
|
||||||
|
|
||||||
|
| Feature | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||||
|
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||||
|
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||||
|
| Browser Testing | Full integration tests in real browsers |
|
||||||
|
| Smoke Testing | Validate multiple pages quickly |
|
||||||
|
| Visual Regression | Compare screenshots for visual changes |
|
||||||
|
| Architecture Testing | Enforce code conventions |
|
||||||
|
|
||||||
|
### Tia (Test Impact Analysis)
|
||||||
|
|
||||||
|
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||||
|
|
||||||
|
@boostsnippet("Tia Example", "shell")
|
||||||
|
./vendor/bin/pest --parallel --tia
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||||
|
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||||
|
|
||||||
|
### New Validation Expectations
|
||||||
|
|
||||||
|
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||||
|
|
||||||
|
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||||
|
expect('nuno@pestphp.com')->toBeEmail();
|
||||||
|
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||||
|
expect('192.168.1.1')->toBeIpAddress();
|
||||||
|
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||||
|
expect('example.com')->toBeHostname();
|
||||||
|
expect('example.co.uk')->toBeDomain();
|
||||||
|
expect('Zm9vYmFy')->toBeBase64();
|
||||||
|
expect('deadbeef')->toBeHexadecimal();
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Time-Balanced Sharding
|
||||||
|
|
||||||
|
Distribute tests across CI shards by execution time rather than count:
|
||||||
|
|
||||||
|
@boostsnippet("Pest Sharding Example", "shell")
|
||||||
|
./vendor/bin/pest --update-shards
|
||||||
|
./vendor/bin/pest --shard=1/4
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||||
|
|
||||||
|
### Browser Test Example
|
||||||
|
|
||||||
|
Browser tests run in real browsers for full integration testing:
|
||||||
|
|
||||||
|
- Browser tests live in `tests/Browser/`.
|
||||||
|
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||||
|
- Use `RefreshDatabase` for clean state per test.
|
||||||
|
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||||
|
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||||
|
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||||
|
- Switch color schemes (light/dark mode) when appropriate.
|
||||||
|
- Take screenshots or pause tests for debugging.
|
||||||
|
|
||||||
|
@boostsnippet("Pest Browser Test Example", "php")
|
||||||
|
it('may reset the password', function () {
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
$page = visit('/sign-in');
|
||||||
|
|
||||||
|
$page->assertSee('Sign In')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->click('Forgot Password?')
|
||||||
|
->fill('email', 'nuno@laravel.com')
|
||||||
|
->click('Send Reset Link')
|
||||||
|
->assertSee('We have emailed your password reset link!');
|
||||||
|
|
||||||
|
Notification::assertSent(ResetPassword::class);
|
||||||
|
});
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Smoke Testing
|
||||||
|
|
||||||
|
Quickly validate multiple pages have no JavaScript errors:
|
||||||
|
|
||||||
|
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||||
|
$pages = visit(['/', '/about', '/contact']);
|
||||||
|
|
||||||
|
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
### Visual Regression Testing
|
||||||
|
|
||||||
|
Capture and compare screenshots to detect visual changes.
|
||||||
|
|
||||||
|
### Architecture Testing
|
||||||
|
|
||||||
|
@boostsnippet("Architecture Test Example", "php")
|
||||||
|
arch('controllers')
|
||||||
|
->expect('App\Http\Controllers')
|
||||||
|
->toExtendNothing()
|
||||||
|
->toHaveSuffix('Controller');
|
||||||
|
@endboostsnippet
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||||
|
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||||
|
- Forgetting datasets for repetitive validation tests
|
||||||
|
- Deleting tests without approval
|
||||||
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray mr-1">- </span>
|
<span class="text-gray mr-1">- </span>
|
||||||
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
|
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
|
|||||||
* @var array<int, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
public const array FILES = [
|
public const array FILES = [
|
||||||
|
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||||
'Runner/Filter/NameFilterIterator.php',
|
'Runner/Filter/NameFilterIterator.php',
|
||||||
'Runner/ResultCache/DefaultResultCache.php',
|
'Runner/ResultCache/DefaultResultCache.php',
|
||||||
'Runner/TestSuiteLoader.php',
|
'Runner/TestSuiteLoader.php',
|
||||||
|
|||||||
+25
-10
@@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
|
|||||||
use PHPUnit\Framework\IncompleteTest;
|
use PHPUnit\Framework\IncompleteTest;
|
||||||
use PHPUnit\Framework\SkippedTest;
|
use PHPUnit\Framework\SkippedTest;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||||
|
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||||
use ReflectionException;
|
use ReflectionException;
|
||||||
use ReflectionFunction;
|
use ReflectionFunction;
|
||||||
use ReflectionParameter;
|
use ReflectionParameter;
|
||||||
@@ -278,12 +280,17 @@ trait Testable
|
|||||||
|
|
||||||
/** @var Tia $tia */
|
/** @var Tia $tia */
|
||||||
$tia = Container::getInstance()->get(Tia::class);
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
|
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||||
$replay = ReplayType::fromStatus($status);
|
$replay = ReplayType::fromStatus($status);
|
||||||
|
|
||||||
if ($replay !== ReplayType::None) {
|
if ($replay !== ReplayType::None) {
|
||||||
assert($status !== null);
|
assert($status !== null);
|
||||||
|
|
||||||
|
// Marks the replay before the branches below throw, so `tearDown`
|
||||||
|
// short-circuits for every replayed result — the throwing branches
|
||||||
|
// never reach `parent::setUp`, so no user hook may run after them.
|
||||||
|
$this->__replay = $replay;
|
||||||
|
|
||||||
match ($replay) {
|
match ($replay) {
|
||||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||||
@@ -317,7 +324,7 @@ trait Testable
|
|||||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||||
{
|
{
|
||||||
$this->__replay = $replay;
|
$this->__replay = $replay;
|
||||||
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||||
$this->__ran = true;
|
$this->__ran = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,15 +438,15 @@ trait Testable
|
|||||||
unset($this->{$property});
|
unset($this->{$property});
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
|
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||||
|
|
||||||
if ($hasOutputExpectation) {
|
if ($outputBuffer->hasExpectation()) {
|
||||||
ob_clean();
|
ob_clean();
|
||||||
|
|
||||||
Closure::bind(function (): void {
|
Closure::bind(function (): void {
|
||||||
$this->outputExpectedString = null;
|
$this->expectedString = null;
|
||||||
$this->outputExpectedRegex = null;
|
$this->expectedRegularExpression = null;
|
||||||
}, $this, TestCase::class)();
|
}, $outputBuffer, OutputBuffer::class)();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->setUp();
|
$this->setUp();
|
||||||
@@ -455,7 +462,9 @@ trait Testable
|
|||||||
*/
|
*/
|
||||||
private function __isExpectedException(Throwable $e): bool
|
private function __isExpectedException(Throwable $e): bool
|
||||||
{
|
{
|
||||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
|
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||||
|
|
||||||
|
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||||
|
|
||||||
$expectedClass = $read('expectedException');
|
$expectedClass = $read('expectedException');
|
||||||
|
|
||||||
@@ -463,13 +472,19 @@ trait Testable
|
|||||||
return $e instanceof $expectedClass;
|
return $e instanceof $expectedClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedMessage = $read('expectedExceptionMessage');
|
$expectedMessage = $read('expectedMessage');
|
||||||
|
|
||||||
if ($expectedMessage !== null) {
|
if ($expectedMessage !== null) {
|
||||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedCode = $read('expectedExceptionCode');
|
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||||
|
|
||||||
|
if ($expectedMessageRegex !== null) {
|
||||||
|
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedCode = $read('expectedCode');
|
||||||
|
|
||||||
if ($expectedCode !== null) {
|
if ($expectedCode !== null) {
|
||||||
return $e->getCode() === $expectedCode;
|
return $e->getCode() === $expectedCode;
|
||||||
|
|||||||
+11
-3
@@ -33,7 +33,7 @@ final readonly class Configuration
|
|||||||
*/
|
*/
|
||||||
public function in(string ...$targets): UsesCall
|
public function in(string ...$targets): UsesCall
|
||||||
{
|
{
|
||||||
return (new UsesCall($this->filename, []))->in(...$targets);
|
return new UsesCall($this->filename, [])->in(...$targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +60,7 @@ final readonly class Configuration
|
|||||||
*/
|
*/
|
||||||
public function group(string ...$groups): UsesCall
|
public function group(string ...$groups): UsesCall
|
||||||
{
|
{
|
||||||
return (new UsesCall($this->filename, []))->group(...$groups);
|
return new UsesCall($this->filename, [])->group(...$groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +68,7 @@ final readonly class Configuration
|
|||||||
*/
|
*/
|
||||||
public function only(): void
|
public function only(): void
|
||||||
{
|
{
|
||||||
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
|
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +119,14 @@ final readonly class Configuration
|
|||||||
return new Browser\Configuration;
|
return new Browser\Configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the evals configuration.
|
||||||
|
*/
|
||||||
|
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
||||||
|
{
|
||||||
|
return new Evals\Configuration; // @phpstan-ignore-line
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the TIA (Test Impact Analysis) configuration.
|
* Gets the TIA (Test Impact Analysis) configuration.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name, string $scope)
|
public function __construct(string $name, string $scope)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $name)
|
public function __construct(string $name)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class DatasetProviderError extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(Throwable $previous)
|
||||||
|
{
|
||||||
|
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $filename)
|
public function __construct(string $filename)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Exceptions;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||||
|
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||||
|
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Creates a new Exception instance for the given class name.
|
||||||
|
*/
|
||||||
|
public static function fromClassName(string $filename, string $className): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||||
|
$filename,
|
||||||
|
$className,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Exception instance for the given namespace.
|
||||||
|
*/
|
||||||
|
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||||
|
{
|
||||||
|
return new self(sprintf(
|
||||||
|
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||||
|
$filename,
|
||||||
|
$namespace,
|
||||||
|
$part,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName, string $description)
|
public function __construct(string $fileName, string $description)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $testCaseClass)
|
public function __construct(string $testCaseClass)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
|||||||
*/
|
*/
|
||||||
public function __construct(string $fileName)
|
public function __construct(string $fileName)
|
||||||
{
|
{
|
||||||
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-13
@@ -130,7 +130,7 @@ final class Expectation
|
|||||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||||
ob_start();
|
ob_start();
|
||||||
var_dump($this->value, ...$arguments);
|
var_dump($this->value, ...$arguments);
|
||||||
$output = (string) ob_get_clean();
|
$output = ob_get_clean();
|
||||||
|
|
||||||
throw new ExpectationFailedException($output);
|
throw new ExpectationFailedException($output);
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ final class Expectation
|
|||||||
if ($callbacks[$index] instanceof Closure) {
|
if ($callbacks[$index] instanceof Closure) {
|
||||||
$callbacks[$index](new self($value), new self($key));
|
$callbacks[$index](new self($value), new self($key));
|
||||||
} else {
|
} else {
|
||||||
(new self($value))->toEqual($callbacks[$index]);
|
new self($value)->toEqual($callbacks[$index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||||
@@ -921,15 +921,7 @@ final class Expectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$this,
|
$this,
|
||||||
function (ObjectDescription $object) use ($interfaces): bool {
|
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||||
foreach ($interfaces as $interface) {
|
|
||||||
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"to implement '".implode("', '", $interfaces)."'",
|
"to implement '".implode("', '", $interfaces)."'",
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
@@ -1144,8 +1136,8 @@ final class Expectation
|
|||||||
$this,
|
$this,
|
||||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||||
&& $object->reflectionClass->isEnum()
|
&& $object->reflectionClass->isEnum()
|
||||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||||
'to be '.$backingType.' backed enum',
|
'to be '.$backingType.' backed enum',
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
|
|||||||
* Dynamically calls methods on the class with the given arguments.
|
* Dynamically calls methods on the class with the given arguments.
|
||||||
*
|
*
|
||||||
* @param array<int, mixed> $arguments
|
* @param array<int, mixed> $arguments
|
||||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
* @return self<TOriginalValue, mixed>
|
||||||
*/
|
*/
|
||||||
public function __call(string $name, array $arguments): self
|
public function __call(string $name, array $arguments): self
|
||||||
{
|
{
|
||||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
|||||||
/**
|
/**
|
||||||
* Accesses properties in the value or in the expectation.
|
* Accesses properties in the value or in the expectation.
|
||||||
*
|
*
|
||||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
* @return self<TOriginalValue, mixed>
|
||||||
*/
|
*/
|
||||||
public function __get(string $name): self
|
public function __get(string $name): self
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$original,
|
$original,
|
||||||
function (ObjectDescription $object) use ($traits): bool {
|
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||||
foreach ($traits as $trait) {
|
|
||||||
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"not to use traits '".implode("', '", $traits)."'",
|
"not to use traits '".implode("', '", $traits)."'",
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
return Targeted::make(
|
return Targeted::make(
|
||||||
$original,
|
$original,
|
||||||
function (ObjectDescription $object) use ($interfaces): bool {
|
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||||
foreach ($interfaces as $interface) {
|
|
||||||
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
"not to implement '".implode("', '", $interfaces)."'",
|
"not to implement '".implode("', '", $interfaces)."'",
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
|
|||||||
|
|
||||||
$exporter = Exporter::default();
|
$exporter = Exporter::default();
|
||||||
|
|
||||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
|
||||||
|
|
||||||
throw new ExpectationFailedException(sprintf(
|
throw new ExpectationFailedException(sprintf(
|
||||||
'Expecting %s not %s %s.',
|
'Expecting %s not %s %s.',
|
||||||
$toString($this->original->value),
|
$exporter->shortenedExport($this->original->value),
|
||||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
|
|||||||
$original,
|
$original,
|
||||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||||
|| ! $object->reflectionClass->isEnum()
|
|| ! $object->reflectionClass->isEnum()
|
||||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||||
'not to be '.$backingType.' backed enum',
|
'not to be '.$backingType.' backed enum',
|
||||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
|||||||
use Pest\Contracts\HasPrintableTestCaseName;
|
use Pest\Contracts\HasPrintableTestCaseName;
|
||||||
use Pest\Evaluators\Attributes;
|
use Pest\Evaluators\Attributes;
|
||||||
use Pest\Exceptions\DatasetMissing;
|
use Pest\Exceptions\DatasetMissing;
|
||||||
|
use Pest\Exceptions\InvalidTestClassName;
|
||||||
use Pest\Exceptions\ShouldNotHappen;
|
use Pest\Exceptions\ShouldNotHappen;
|
||||||
use Pest\Exceptions\TestAlreadyExist;
|
use Pest\Exceptions\TestAlreadyExist;
|
||||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||||
@@ -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 = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
$realpath = (string) realpath($filename);
|
||||||
|
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||||
$rootPath = TestSuite::getInstance()->rootPath;
|
$rootPath = TestSuite::getInstance()->rootPath;
|
||||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||||
|
|
||||||
@@ -137,6 +139,16 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
if (trim($className) === '') {
|
if (trim($className) === '') {
|
||||||
$className = 'InvalidTestName'.Str::random();
|
$className = 'InvalidTestName'.Str::random();
|
||||||
|
} elseif (! Str::isValidClassName($className)) {
|
||||||
|
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->namespace === null) {
|
||||||
|
foreach ($partsFQN as $partFQN) {
|
||||||
|
if (! Str::isValidIdentifier($partFQN)) {
|
||||||
|
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->attributes = [
|
$this->attributes = [
|
||||||
@@ -149,6 +161,8 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
$attributesCode = Attributes::code($this->attributes);
|
$attributesCode = Attributes::code($this->attributes);
|
||||||
|
|
||||||
|
$filenameLiteral = var_export($realpath, true);
|
||||||
|
|
||||||
$methodsCode = implode('', array_map(
|
$methodsCode = implode('', array_map(
|
||||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||||
$methods
|
$methods
|
||||||
@@ -158,6 +172,7 @@ final class TestCaseFactory
|
|||||||
$classCode = <<<PHP
|
$classCode = <<<PHP
|
||||||
namespace $namespace;
|
namespace $namespace;
|
||||||
|
|
||||||
|
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||||
use Pest\TestSuite as __PestTestSuite;
|
use Pest\TestSuite as __PestTestSuite;
|
||||||
|
|
||||||
@@ -166,7 +181,7 @@ final class TestCaseFactory
|
|||||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||||
$traitsCode
|
$traitsCode
|
||||||
|
|
||||||
public static \$__filename = '$filename';
|
public static \$__filename = $filenameLiteral;
|
||||||
|
|
||||||
$methodsCode
|
$methodsCode
|
||||||
}
|
}
|
||||||
@@ -197,7 +212,7 @@ final class TestCaseFactory
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$method->closure instanceof \Closure &&
|
$method->closure instanceof \Closure &&
|
||||||
(new \ReflectionFunction($method->closure))->isStatic()
|
new \ReflectionFunction($method->closure)->isStatic()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
throw new TestClosureMustNotBeStatic($method);
|
throw new TestClosureMustNotBeStatic($method);
|
||||||
|
|||||||
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
|
|||||||
$attributesCode
|
$attributesCode
|
||||||
public function $methodName(...\$arguments)
|
public function $methodName(...\$arguments)
|
||||||
{
|
{
|
||||||
|
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||||
|
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||||
|
}
|
||||||
|
|
||||||
return \$this->__runTest(
|
return \$this->__runTest(
|
||||||
\$this->__test,
|
\$this->__test,
|
||||||
...\$arguments,
|
...\$arguments,
|
||||||
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
|
|||||||
|
|
||||||
public static function $dataProviderName()
|
public static function $dataProviderName()
|
||||||
{
|
{
|
||||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
try {
|
||||||
|
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||||
|
} catch (\Throwable \$throwable) {
|
||||||
|
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EOF;
|
EOF;
|
||||||
|
|||||||
+21
-22
@@ -15,11 +15,6 @@ use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
|||||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\ConsideredRisky;
|
|
||||||
use PHPUnit\Event\Test\Errored;
|
|
||||||
use PHPUnit\Event\Test\Failed;
|
|
||||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
|
||||||
use PHPUnit\Event\Test\Skipped;
|
|
||||||
use PHPUnit\Event\TestSuite\TestSuite;
|
use PHPUnit\Event\TestSuite\TestSuite;
|
||||||
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
||||||
use PHPUnit\Framework\Exception as FrameworkException;
|
use PHPUnit\Framework\Exception as FrameworkException;
|
||||||
@@ -254,25 +249,29 @@ final readonly class Converter
|
|||||||
...$result->testMarkedIncompleteEvents(),
|
...$result->testMarkedIncompleteEvents(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$numberOfNotPassedTests = count(
|
$notPassedTests = [];
|
||||||
array_unique(
|
|
||||||
array_map(
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->getTestCaseLocation($event->test());
|
foreach ($events as $event) {
|
||||||
},
|
if ($event instanceof AfterLastTestMethodErrored) {
|
||||||
$events
|
// 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);
|
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
|||||||
{
|
{
|
||||||
public function notify(Skipped $event): void
|
public function notify(Skipped $event): void
|
||||||
{
|
{
|
||||||
|
if ($event->message() === '__TODO__') {
|
||||||
|
return; // "todo" tests are reported in the summary, not as ignored tests...
|
||||||
|
}
|
||||||
|
|
||||||
$this->logger()->testSkipped($event);
|
$this->logger()->testSkipped($event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,12 @@ final class TeamCityLogger
|
|||||||
$telemetry->memoryUsageSinceStart(),
|
$telemetry->memoryUsageSinceStart(),
|
||||||
$telemetry->durationSincePrevious(),
|
$telemetry->durationSincePrevious(),
|
||||||
$telemetry->memoryUsageSincePrevious(),
|
$telemetry->memoryUsageSincePrevious(),
|
||||||
|
$telemetry->userCpuTimeSinceStart(),
|
||||||
|
$telemetry->systemCpuTimeSinceStart(),
|
||||||
|
$telemetry->totalCpuTimeSinceStart(),
|
||||||
|
$telemetry->userCpuTimeSincePrevious(),
|
||||||
|
$telemetry->systemCpuTimeSincePrevious(),
|
||||||
|
$telemetry->totalCpuTimeSincePrevious(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+133
-1
@@ -936,7 +936,7 @@ final class Expectation
|
|||||||
|
|
||||||
if ($exception instanceof Closure) {
|
if ($exception instanceof Closure) {
|
||||||
$callback = $exception;
|
$callback = $exception;
|
||||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||||
|
|
||||||
if (count($parameters) !== 1) {
|
if (count($parameters) !== 1) {
|
||||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||||
@@ -1142,6 +1142,22 @@ final class Expectation
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a ULID.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeUlid(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue(Str::isUlid($this->value), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that the value is between 2 specified values
|
* Asserts that the value is between 2 specified values
|
||||||
*
|
*
|
||||||
@@ -1155,6 +1171,22 @@ final class Expectation
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is an email address.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeEmail(string $message = ''): self
|
||||||
|
{
|
||||||
|
if ($message === '') {
|
||||||
|
$message = "Failed asserting that {$this->value} is an email address.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asserts that the value is a url
|
* Asserts that the value is a url
|
||||||
*
|
*
|
||||||
@@ -1187,4 +1219,104 @@ final class Expectation
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is an IP address.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeIpAddress(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a MAC address.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeMacAddress(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a hostname.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeHostname(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a domain name.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeDomain(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
|
||||||
|
&& str_contains($this->value, '.');
|
||||||
|
|
||||||
|
Assert::assertTrue($isValid, $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a base64-encoded string.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeBase64(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($this->value, true);
|
||||||
|
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts that the value is a hexadecimal string.
|
||||||
|
*
|
||||||
|
* @return self<TValue>
|
||||||
|
*/
|
||||||
|
public function toBeHexadecimal(string $message = ''): self
|
||||||
|
{
|
||||||
|
if (! is_string($this->value)) {
|
||||||
|
InvalidExpectationValue::expected('string');
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert::assertTrue(ctype_xdigit($this->value), $message);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use PHPStan\Type\Type;
|
|||||||
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
||||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||||
*
|
*
|
||||||
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
|
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
|
|||||||
|
|
||||||
$varType = $scope->getType($expr->var);
|
$varType = $scope->getType($expr->var);
|
||||||
|
|
||||||
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
|
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ final class UsesCall
|
|||||||
$this->targets = [$filename];
|
$this->targets = [$filename];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
|
||||||
*/
|
|
||||||
public function compact(): self
|
public function compact(): self
|
||||||
{
|
{
|
||||||
DefaultPrinter::compact(true);
|
DefaultPrinter::compact(true);
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
|||||||
|
|
||||||
function version(): string
|
function version(): string
|
||||||
{
|
{
|
||||||
return '4.7.4';
|
return '5.0.3';
|
||||||
}
|
}
|
||||||
|
|
||||||
function testDirectory(string $file = ''): string
|
function testDirectory(string $file = ''): string
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||||
return $this->pushArgument('--cache-result', $arguments);
|
return $this->pushArgument('--cache-result', $arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,14 @@ trait HandleArguments
|
|||||||
*/
|
*/
|
||||||
public function popArgument(string $argument, array $arguments): array
|
public function popArgument(string $argument, array $arguments): array
|
||||||
{
|
{
|
||||||
$arguments = array_flip($arguments);
|
$key = array_search($argument, $arguments, true);
|
||||||
|
|
||||||
unset($arguments[$argument]);
|
while ($key !== false) {
|
||||||
|
unset($arguments[$key]);
|
||||||
|
$key = array_search($argument, $arguments, true);
|
||||||
|
}
|
||||||
|
|
||||||
return array_values(array_flip($arguments));
|
return array_values($arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
*/
|
*/
|
||||||
final class Coverage implements AddsOutput, HandlesArguments
|
final class Coverage implements AddsOutput, HandlesArguments
|
||||||
{
|
{
|
||||||
|
use Concerns\HandleArguments;
|
||||||
|
|
||||||
private const string COVERAGE_OPTION = 'coverage';
|
private const string COVERAGE_OPTION = 'coverage';
|
||||||
|
|
||||||
private const string MIN_OPTION = 'min';
|
private const string MIN_OPTION = 'min';
|
||||||
@@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
|
|||||||
return false;
|
return false;
|
||||||
}))];
|
}))];
|
||||||
|
|
||||||
$originals = array_flip($originals);
|
|
||||||
foreach ($arguments as $argument) {
|
foreach ($arguments as $argument) {
|
||||||
unset($originals[$argument]);
|
$originals = $this->popArgument($argument, $originals);
|
||||||
}
|
}
|
||||||
$originals = array_flip($originals);
|
|
||||||
|
|
||||||
$inputs = [];
|
$inputs = [];
|
||||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||||
|
|||||||
@@ -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'] = [...[
|
$content['Execution'] = [...[
|
||||||
[
|
[
|
||||||
'arg' => '--parallel',
|
'arg' => '--parallel',
|
||||||
|
|||||||
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
|
|||||||
{
|
{
|
||||||
$arguments = new ArgvInput;
|
$arguments = new ArgvInput;
|
||||||
|
|
||||||
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
|
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
|
||||||
if ($arguments->hasParameterOption($unsupportedArgument)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ final class ResultPrinter
|
|||||||
|
|
||||||
$unexpectedOutput = $this->tail($outputFile);
|
$unexpectedOutput = $this->tail($outputFile);
|
||||||
if ($unexpectedOutput !== '') {
|
if ($unexpectedOutput !== '') {
|
||||||
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->output->write($unexpectedOutput);
|
$this->output->write($unexpectedOutput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ namespace Pest\Plugins\Parallel\Paratest;
|
|||||||
use const DIRECTORY_SEPARATOR;
|
use const DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
|
||||||
use ParaTest\Coverage\CoverageMerger;
|
|
||||||
use ParaTest\JUnit\LogMerger;
|
use ParaTest\JUnit\LogMerger;
|
||||||
use ParaTest\JUnit\Writer;
|
use ParaTest\JUnit\Writer;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
use ParaTest\RunnerInterface;
|
use ParaTest\RunnerInterface;
|
||||||
|
use ParaTest\WrapperRunner\MissingResultsException;
|
||||||
use ParaTest\WrapperRunner\SuiteLoader;
|
use ParaTest\WrapperRunner\SuiteLoader;
|
||||||
use ParaTest\WrapperRunner\WrapperWorker;
|
use ParaTest\WrapperRunner\WrapperWorker;
|
||||||
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Result;
|
use Pest\Result;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
use PHPUnit\Event\Facade as EventFacade;
|
use PHPUnit\Event\Facade as EventFacade;
|
||||||
@@ -25,11 +26,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
|||||||
use PHPUnit\TestRunner\TestResult\TestResult;
|
use PHPUnit\TestRunner\TestResult\TestResult;
|
||||||
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
|
||||||
use PHPUnit\Util\ExcludeList;
|
use PHPUnit\Util\ExcludeList;
|
||||||
|
use ReflectionProperty;
|
||||||
|
use SebastianBergmann\CodeCoverage\Node\Builder;
|
||||||
|
use SebastianBergmann\CodeCoverage\Serialization\Merger;
|
||||||
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
|
||||||
|
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
|
||||||
use SebastianBergmann\Timer\Timer;
|
use SebastianBergmann\Timer\Timer;
|
||||||
use SplFileInfo;
|
use SplFileInfo;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
|
use function array_filter;
|
||||||
use function array_merge;
|
use function array_merge;
|
||||||
use function array_merge_recursive;
|
use function array_merge_recursive;
|
||||||
use function array_shift;
|
use function array_shift;
|
||||||
@@ -37,6 +44,8 @@ use function assert;
|
|||||||
use function count;
|
use function count;
|
||||||
use function dirname;
|
use function dirname;
|
||||||
use function file_get_contents;
|
use function file_get_contents;
|
||||||
|
use function filesize;
|
||||||
|
use function is_file;
|
||||||
use function max;
|
use function max;
|
||||||
use function realpath;
|
use function realpath;
|
||||||
use function str_starts_with;
|
use function str_starts_with;
|
||||||
@@ -49,14 +58,14 @@ use function usleep;
|
|||||||
*/
|
*/
|
||||||
final class WrapperRunner implements RunnerInterface
|
final class WrapperRunner implements RunnerInterface
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* The time to sleep between cycles.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* The merged test result from the parallel run.
|
* The merged test result from the parallel run.
|
||||||
*/
|
*/
|
||||||
public static ?TestResult $result = null;
|
public static ?TestResult $result = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time to sleep between cycles.
|
||||||
|
*/
|
||||||
private const int CYCLE_SLEEP = 10000;
|
private const int CYCLE_SLEEP = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,6 +92,18 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
/** @var array<int,int> */
|
/** @var array<int,int> */
|
||||||
private array $batches = [];
|
private array $batches = [];
|
||||||
|
|
||||||
|
/** @var array<non-empty-string,true> */
|
||||||
|
private array $requiredTestResultFiles = [];
|
||||||
|
|
||||||
|
/** @var array<non-empty-string,true> */
|
||||||
|
private array $requiredCoverageFiles = [];
|
||||||
|
|
||||||
|
/** @var list<SplFileInfo> */
|
||||||
|
private array $statusFiles = [];
|
||||||
|
|
||||||
|
/** @var list<SplFileInfo> */
|
||||||
|
private array $progressFiles = [];
|
||||||
|
|
||||||
/** @var list<SplFileInfo> */
|
/** @var list<SplFileInfo> */
|
||||||
private array $unexpectedOutputFiles = [];
|
private array $unexpectedOutputFiles = [];
|
||||||
|
|
||||||
@@ -135,6 +156,7 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
/** @var array<int, non-empty-string> $parameters */
|
/** @var array<int, non-empty-string> $parameters */
|
||||||
$parameters = $this->handleLaravelHerd($parameters);
|
$parameters = $this->handleLaravelHerd($parameters);
|
||||||
|
$parameters = $this->handleTia($parameters);
|
||||||
|
|
||||||
$parameters[] = $wrapper;
|
$parameters[] = $wrapper;
|
||||||
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
|
||||||
@@ -182,6 +204,28 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
return $parameters;
|
return $parameters;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widens pcov's instrumentation scope to the whole project for workers that
|
||||||
|
* record TIA edges.
|
||||||
|
*
|
||||||
|
* pcov's default scope is a single source directory it auto-detects, so
|
||||||
|
* `config/`, `routes/`, `bootstrap/` and every test's own file never reach
|
||||||
|
* the recorder — a worker-recorded graph selects a fraction of what a
|
||||||
|
* sequential one does. `pcov.directory` is only settable at startup, hence
|
||||||
|
* the command line rather than an `ini_set()` inside the worker.
|
||||||
|
*
|
||||||
|
* @param array<int, non-empty-string> $parameters
|
||||||
|
* @return array<int, non-empty-string>
|
||||||
|
*/
|
||||||
|
private function handleTia(array $parameters): array
|
||||||
|
{
|
||||||
|
if (! Tia::recordsEdgesInWorkers()) {
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
|
||||||
|
}
|
||||||
|
|
||||||
private function startWorkers(): void
|
private function startWorkers(): void
|
||||||
{
|
{
|
||||||
for ($token = 1; $token <= $this->options->processes; $token++) {
|
for ($token = 1; $token <= $this->options->processes; $token++) {
|
||||||
@@ -212,7 +256,7 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
$this->exitcode > 0
|
$this->exitcode > 0
|
||||||
&& $this->options->configuration->stopOnFailure()
|
&& $this->options->configuration->stopOnFailureThreshold() > 0
|
||||||
) {
|
) {
|
||||||
$this->pending = [];
|
$this->pending = [];
|
||||||
} elseif (($pending = array_shift($this->pending)) !== null) {
|
} elseif (($pending = array_shift($this->pending)) !== null) {
|
||||||
@@ -227,6 +271,18 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
private function flushWorker(WrapperWorker $worker): void
|
private function flushWorker(WrapperWorker $worker): void
|
||||||
{
|
{
|
||||||
|
if ($worker->hasExecutedTests()) {
|
||||||
|
$testResultFile = $worker->testResultFile->getPathname();
|
||||||
|
|
||||||
|
if ($testResultFile !== '') {
|
||||||
|
$this->requiredTestResultFiles[$testResultFile] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
|
||||||
|
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->exitcode = max($this->exitcode, $worker->getExitCode());
|
$this->exitcode = max($this->exitcode, $worker->getExitCode());
|
||||||
$this->printer->printFeedback(
|
$this->printer->printFeedback(
|
||||||
$worker->progressFile,
|
$worker->progressFile,
|
||||||
@@ -274,10 +330,15 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
$worker->start();
|
$worker->start();
|
||||||
$this->batches[$token] = 0;
|
$this->batches[$token] = 0;
|
||||||
|
|
||||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
$this->statusFiles[] = $worker->statusFile;
|
||||||
|
$this->progressFiles[] = $worker->progressFile;
|
||||||
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
|
||||||
$this->testResultFiles[] = $worker->testResultFile;
|
$this->testResultFiles[] = $worker->testResultFile;
|
||||||
|
|
||||||
|
if (isset($worker->resultCacheFile)) {
|
||||||
|
$this->resultCacheFiles[] = $worker->resultCacheFile;
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($worker->junitFile)) {
|
if (isset($worker->junitFile)) {
|
||||||
$this->junitFiles[] = $worker->junitFile;
|
$this->junitFiles[] = $worker->junitFile;
|
||||||
}
|
}
|
||||||
@@ -310,6 +371,20 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
private function complete(TestResult $testResultSum): int
|
private function complete(TestResult $testResultSum): int
|
||||||
{
|
{
|
||||||
|
$missingTestResultFiles = [];
|
||||||
|
|
||||||
|
foreach ($this->requiredTestResultFiles as $filePath => $true) {
|
||||||
|
if (is_file($filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingTestResultFiles[] = $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingTestResultFiles !== []) {
|
||||||
|
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($this->testResultFiles as $testResultFile) {
|
foreach ($this->testResultFiles as $testResultFile) {
|
||||||
if (! $testResultFile->isFile()) {
|
if (! $testResultFile->isFile()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -344,6 +419,20 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
|
||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueDeprecationEvents(), $testResult->testRunnerTriggeredIssueDeprecationEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueErrorEvents(), $testResult->testRunnerTriggeredIssueErrorEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueNoticeEvents(), $testResult->testRunnerTriggeredIssueNoticeEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(), $testResult->testRunnerTriggeredIssuePhpDeprecationEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(), $testResult->testRunnerTriggeredIssuePhpNoticeEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpWarningEvents(), $testResult->testRunnerTriggeredIssuePhpWarningEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
|
array_merge_recursive($testResultSum->testRunnerTriggeredIssueWarningEvents(), $testResult->testRunnerTriggeredIssueWarningEvents()),
|
||||||
|
// @phpstan-ignore-next-line
|
||||||
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
|
||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
|
||||||
@@ -379,8 +468,15 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
$testResultSum->testRunnerTriggeredNoticeEvents(),
|
||||||
array_values(array_filter(
|
array_values(array_filter(
|
||||||
$testResultSum->testRunnerTriggeredWarningEvents(),
|
$testResultSum->testRunnerTriggeredWarningEvents(),
|
||||||
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
|
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
|
||||||
)),
|
)),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
|
||||||
|
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
|
||||||
$testResultSum->errors(),
|
$testResultSum->errors(),
|
||||||
$testResultSum->deprecations(),
|
$testResultSum->deprecations(),
|
||||||
$testResultSum->notices(),
|
$testResultSum->notices(),
|
||||||
@@ -416,8 +512,11 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
|
||||||
|
|
||||||
|
$this->clearFiles($this->statusFiles);
|
||||||
|
$this->clearFiles($this->progressFiles);
|
||||||
$this->clearFiles($this->unexpectedOutputFiles);
|
$this->clearFiles($this->unexpectedOutputFiles);
|
||||||
$this->clearFiles($this->testResultFiles);
|
$this->clearFiles($this->testResultFiles);
|
||||||
|
$this->clearFiles($this->resultCacheFiles);
|
||||||
$this->clearFiles($this->coverageFiles);
|
$this->clearFiles($this->coverageFiles);
|
||||||
$this->clearFiles($this->junitFiles);
|
$this->clearFiles($this->junitFiles);
|
||||||
$this->clearFiles($this->teamcityFiles);
|
$this->clearFiles($this->teamcityFiles);
|
||||||
@@ -432,6 +531,20 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$missingCoverageFiles = [];
|
||||||
|
|
||||||
|
foreach ($this->requiredCoverageFiles as $filePath => $true) {
|
||||||
|
if (is_file($filePath) && filesize($filePath) !== 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingCoverageFiles[] = $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missingCoverageFiles !== []) {
|
||||||
|
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
|
||||||
|
}
|
||||||
|
|
||||||
$coverageManager = new CodeCoverage;
|
$coverageManager = new CodeCoverage;
|
||||||
$coverageManager->init(
|
$coverageManager->init(
|
||||||
$this->options->configuration,
|
$this->options->configuration,
|
||||||
@@ -447,10 +560,33 @@ final class WrapperRunner implements RunnerInterface
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
|
$coverageFiles = [];
|
||||||
foreach ($this->coverageFiles as $coverageFile) {
|
foreach ($this->coverageFiles as $fileInfo) {
|
||||||
$coverageMerger->addCoverageFromFile($coverageFile);
|
$realPath = $fileInfo->getRealPath();
|
||||||
|
if ($realPath !== false && $realPath !== '') {
|
||||||
|
$coverageFiles[] = $realPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
$serializedCoverage = (new Merger)->merge($coverageFiles);
|
||||||
|
|
||||||
|
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
|
||||||
|
$serializedCoverage['codeCoverage'],
|
||||||
|
$serializedCoverage['testResults'],
|
||||||
|
$serializedCoverage['basePath'],
|
||||||
|
);
|
||||||
|
$codeCoverage = $coverageManager->codeCoverage();
|
||||||
|
$codeCoverage->excludeUncoveredFiles();
|
||||||
|
|
||||||
|
$mergedData = $serializedCoverage['codeCoverage'];
|
||||||
|
$basePath = $serializedCoverage['basePath'];
|
||||||
|
if ($basePath !== '') {
|
||||||
|
foreach ($mergedData->coveredFiles() as $relativePath) {
|
||||||
|
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$codeCoverage->setData($mergedData);
|
||||||
|
$codeCoverage->setTests($serializedCoverage['testResults']);
|
||||||
|
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
|
||||||
|
|
||||||
$coverageManager->generateReports(
|
$coverageManager->generateReports(
|
||||||
$this->printer->printer,
|
$this->printer->printer,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
|
|||||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||||
use ParaTest\Options;
|
use ParaTest\Options;
|
||||||
|
use PHPUnit\Event\Telemetry\CpuTime;
|
||||||
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
|
||||||
use PHPUnit\Event\Telemetry\HRTime;
|
use PHPUnit\Event\Telemetry\HRTime;
|
||||||
use PHPUnit\Event\Telemetry\Info;
|
use PHPUnit\Event\Telemetry\Info;
|
||||||
@@ -147,11 +148,20 @@ final class CompactPrinter
|
|||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
$garbageCollectorStatus,
|
$garbageCollectorStatus,
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
),
|
),
|
||||||
$telemetryDuration,
|
$telemetryDuration,
|
||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
|
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
|
||||||
MemoryUsage::fromBytes(0),
|
MemoryUsage::fromBytes(0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
|
CpuTime::fromSecondsAndNanoseconds(0, 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->style->writeRecap($state, $telemetry, $testResult);
|
$this->style->writeRecap($state, $telemetry, $testResult);
|
||||||
|
|||||||
+105
-12
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string SHARD_OPTION = 'shard';
|
private const string SHARD_OPTION = 'shard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum length allowed for the filter argument.
|
||||||
|
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
|
||||||
|
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
|
||||||
|
*/
|
||||||
|
private const int MAX_FILTER_LENGTH = 32768;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shard index and total number of shards.
|
* The shard index and total number of shards.
|
||||||
*
|
*
|
||||||
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
self::$timeBalanced = true;
|
self::$timeBalanced = true;
|
||||||
self::$shardsOutdated = $newTests !== [];
|
self::$shardsOutdated = $newTests !== [];
|
||||||
} else {
|
} else {
|
||||||
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
|
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
|
||||||
|
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$shard = [
|
self::$shard = [
|
||||||
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
|
$filter = $this->buildFilterArgument($testsToRun);
|
||||||
|
|
||||||
|
$this->ensureFilterLengthIsSafe($filter);
|
||||||
|
|
||||||
|
return [...$arguments, '--filter', $filter];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function allTests(array $arguments): array
|
private function allTests(array $arguments): array
|
||||||
{
|
{
|
||||||
$output = (new Process([
|
$command = $this->buildListTestsCommand(
|
||||||
'php',
|
$arguments,
|
||||||
...$this->removeParallelArguments($arguments),
|
TestSuite::getInstance()->testPath,
|
||||||
'--list-tests',
|
);
|
||||||
]))->setTimeout(120)->mustRun()->getOutput();
|
|
||||||
|
|
||||||
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
|
$output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
|
||||||
|
|
||||||
return array_values(array_unique($matches[1]));
|
return $this->parseListTestsOutput($output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
|
|||||||
*/
|
*/
|
||||||
private function removeParallelArguments(array $arguments): array
|
private function removeParallelArguments(array $arguments): array
|
||||||
{
|
{
|
||||||
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
|
return array_values(array_filter(
|
||||||
|
$arguments,
|
||||||
|
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
|
||||||
|
&& ! str_starts_with($argument, '--processes'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the subprocess command used to enumerate tests via `--list-tests`.
|
||||||
|
*
|
||||||
|
* @param list<string> $arguments
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function buildListTestsCommand(array $arguments, string $testPath): array
|
||||||
|
{
|
||||||
|
$filtered = $this->removeParallelArguments($arguments);
|
||||||
|
|
||||||
|
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `--list-tests` output into a unique list of test class FQCNs.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function parseListTestsOutput(string $output): array
|
||||||
|
{
|
||||||
|
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
|
||||||
|
|
||||||
|
return array_values(array_unique($matches[1]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the filter argument for the given tests to run.
|
* Builds the filter argument for the given tests to run.
|
||||||
|
*
|
||||||
|
* @param array<int, string> $testsToRun
|
||||||
*/
|
*/
|
||||||
private function buildFilterArgument(mixed $testsToRun): string
|
private function buildFilterArgument(array $testsToRun): string
|
||||||
{
|
{
|
||||||
return addslashes(implode('|', $testsToRun));
|
if ($testsToRun === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $tree */
|
||||||
|
$tree = [];
|
||||||
|
foreach ($testsToRun as $class) {
|
||||||
|
$parts = explode('\\', $class);
|
||||||
|
$current = &$tree;
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
if (! isset($current[$part])) {
|
||||||
|
$current[$part] = [];
|
||||||
|
}
|
||||||
|
$current = &$current[$part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildRegex = function (array $tree) use (&$buildRegex): string {
|
||||||
|
$parts = [];
|
||||||
|
foreach ($tree as $key => $sub) {
|
||||||
|
$subRegex = $buildRegex($sub);
|
||||||
|
if ($subRegex === '') {
|
||||||
|
$parts[] = preg_quote($key, '/');
|
||||||
|
} else {
|
||||||
|
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('|', $parts);
|
||||||
|
};
|
||||||
|
|
||||||
|
return $buildRegex($tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures that the filter length is safe for the current environment.
|
||||||
|
*
|
||||||
|
* @throws InvalidOption
|
||||||
|
*/
|
||||||
|
private function ensureFilterLengthIsSafe(string $filter): void
|
||||||
|
{
|
||||||
|
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
|
||||||
|
|
||||||
|
if (strlen($filter) > $maxLength) {
|
||||||
|
throw new InvalidOption(sprintf(
|
||||||
|
'The generated filter for this shard is too long (%d characters). '.
|
||||||
|
'This can cause issues with some environments (limit is %d characters). '.
|
||||||
|
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
|
||||||
|
strlen($filter),
|
||||||
|
$maxLength
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+692
-70
File diff suppressed because it is too large
Load Diff
@@ -219,6 +219,73 @@ final readonly class ChangedFiles
|
|||||||
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
return $branch === '' || $branch === 'HEAD' ? null : $branch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repository's default branch — the one every other branch's baseline
|
||||||
|
* falls back to reading.
|
||||||
|
*
|
||||||
|
* Advisory, unlike {@see self::currentBranch()}: a repository that cannot
|
||||||
|
* answer the question is not a broken repository. A remote-less checkout
|
||||||
|
* has no `origin/HEAD`, and plenty of CI checkouts never run
|
||||||
|
* `git remote set-head`, so every step here fails soft and the caller is
|
||||||
|
* left to pick its own default.
|
||||||
|
*/
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
{
|
||||||
|
$head = $this->gitOutput(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
||||||
|
|
||||||
|
if ($head !== null) {
|
||||||
|
$branch = preg_replace('#^origin/#', '', $head);
|
||||||
|
|
||||||
|
if (is_string($branch) && $branch !== '') {
|
||||||
|
return $branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `init.defaultBranch` is a setting of the machine, not of the
|
||||||
|
// repository — it names what `git init` would have called the first
|
||||||
|
// branch here, which is worth nothing once the repository disagrees.
|
||||||
|
// Taken only when a branch by that name actually exists.
|
||||||
|
$configured = $this->gitOutput(['git', 'config', '--get', 'init.defaultBranch']);
|
||||||
|
|
||||||
|
if ($configured === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/heads/'.$configured]) !== null
|
||||||
|
|| $this->gitOutput(['git', 'rev-parse', '--verify', '--quiet', 'refs/remotes/origin/'.$configured]) !== null;
|
||||||
|
|
||||||
|
return $exists ? $configured : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the repository has any remote configured.
|
||||||
|
*
|
||||||
|
* Advisory like {@see self::defaultBranch()} — a `git` that cannot answer
|
||||||
|
* is reported as "no remote", and the caller decides what that means.
|
||||||
|
*/
|
||||||
|
public function hasRemote(): bool
|
||||||
|
{
|
||||||
|
return $this->gitOutput(['git', 'remote']) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $command
|
||||||
|
*/
|
||||||
|
private function gitOutput(array $command): ?string
|
||||||
|
{
|
||||||
|
$process = new Process($command, $this->projectRoot);
|
||||||
|
$process->setTimeout(5.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = trim($process->getOutput());
|
||||||
|
|
||||||
|
return $output === '' ? null : $output;
|
||||||
|
}
|
||||||
|
|
||||||
private function shaIsReachable(string $sha): bool
|
private function shaIsReachable(string $sha): bool
|
||||||
{
|
{
|
||||||
$process = new Process(
|
$process = new Process(
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default branch as the CI provider itself reports it.
|
||||||
|
*
|
||||||
|
* Worth asking before git: a CI checkout is the one place where git knows the
|
||||||
|
* least. `actions/checkout` builds the working copy with `git init` plus a
|
||||||
|
* single-ref `fetch` rather than a `clone`, so `origin/HEAD` is never set and
|
||||||
|
* `init.defaultBranch` — a setting of the runner image, not of the repository —
|
||||||
|
* is all git has left to offer. The provider, meanwhile, states the answer
|
||||||
|
* outright in the environment it handed us.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class CiDefaultBranch
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Advisory, like every other source in the chain: anything unreadable,
|
||||||
|
* unparsable, or simply absent means "no answer", never a failure.
|
||||||
|
*/
|
||||||
|
public static function detect(): ?string
|
||||||
|
{
|
||||||
|
return self::fromGitLab() ?? self::fromGitHubEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fromGitLab(): ?string
|
||||||
|
{
|
||||||
|
return self::environment('CI_DEFAULT_BRANCH');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub publishes no default-branch variable, but every repository-scoped
|
||||||
|
* event payload carries `repository.default_branch`, and the path to that
|
||||||
|
* payload is in the environment.
|
||||||
|
*/
|
||||||
|
private static function fromGitHubEvent(): ?string
|
||||||
|
{
|
||||||
|
$path = self::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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function environment(string $name): ?string
|
||||||
|
{
|
||||||
|
$value = getenv($name);
|
||||||
|
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim($value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,24 @@ final class Configuration
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branch whose baseline every other branch falls back to reading.
|
||||||
|
*
|
||||||
|
* Autodetected from the repository when left unset; declare it here when
|
||||||
|
* the repository cannot answer for itself — no `origin/HEAD`, or an
|
||||||
|
* `init.defaultBranch` that disagrees with reality.
|
||||||
|
*
|
||||||
|
* @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
|
* @param array<string, string> $patterns glob → project-relative test dir
|
||||||
* @return $this
|
* @return $this
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
interface Lockfile
|
||||||
|
{
|
||||||
|
public function applies(string $filename): bool;
|
||||||
|
|
||||||
|
public function fingerprint(string $contents): ?string;
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ use Pest\Plugins\Tia;
|
|||||||
use Pest\Plugins\Tia\Contracts\State;
|
use Pest\Plugins\Tia\Contracts\State;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||||
|
use SebastianBergmann\CodeCoverage\Driver\Selector;
|
||||||
|
use SebastianBergmann\CodeCoverage\Filter;
|
||||||
|
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +164,60 @@ final class CoverageMerger
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $value instanceof CodeCoverage ? $value : null;
|
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
|
||||||
|
if ($value instanceof CodeCoverage) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
|
||||||
|
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
|
||||||
|
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
|
||||||
|
// be rebuilt into one before it can be merged.
|
||||||
|
return self::coverageFromSerializedData($reportPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
|
||||||
|
{
|
||||||
|
if ($reportPath === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$serialized = new Unserializer()->unserialize($reportPath);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $serialized['codeCoverage'];
|
||||||
|
$basePath = $serialized['basePath'];
|
||||||
|
|
||||||
|
if ($basePath !== '') {
|
||||||
|
foreach ($data->coveredFiles() as $relativePath) {
|
||||||
|
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage = self::emptyCoverage();
|
||||||
|
|
||||||
|
if (! $coverage instanceof CodeCoverage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coverage->setData($data);
|
||||||
|
$coverage->setTests($serialized['testResults']);
|
||||||
|
|
||||||
|
return $coverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function emptyCoverage(): ?CodeCoverage
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$filter = new Filter;
|
||||||
|
|
||||||
|
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ final class FileState implements State
|
|||||||
$keys = [];
|
$keys = [];
|
||||||
|
|
||||||
foreach ($matches as $path) {
|
foreach ($matches as $path) {
|
||||||
|
if (str_ends_with($path, '.tmp')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$keys[] = basename($path);
|
$keys[] = basename($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Pest\Plugins\Tia;
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||||
use Symfony\Component\Finder\Finder;
|
use Symfony\Component\Finder\Finder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,7 +12,14 @@ use Symfony\Component\Finder\Finder;
|
|||||||
*/
|
*/
|
||||||
final readonly class Fingerprint
|
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{
|
* @return array{
|
||||||
@@ -205,7 +213,11 @@ final readonly class Fingerprint
|
|||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
|
||||||
$hash = self::trackedHash($projectRoot, $name);
|
if (! self::isTrackedByGit($projectRoot, $name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
|
||||||
|
|
||||||
if ($hash !== null) {
|
if ($hash !== null) {
|
||||||
$parts[] = $name.':'.$hash;
|
$parts[] = $name.':'.$hash;
|
||||||
@@ -215,6 +227,37 @@ final readonly class Fingerprint
|
|||||||
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
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
|
private static function trackedHash(string $projectRoot, string $relativePath): ?string
|
||||||
{
|
{
|
||||||
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
|
||||||
|
|||||||
+165
-20
@@ -48,6 +48,17 @@ final class Graph
|
|||||||
*/
|
*/
|
||||||
private array $baselines = [];
|
private array $baselines = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The baseline a branch with none of its own reads from.
|
||||||
|
*
|
||||||
|
* Only ever read from: a branch writes to its own key, so a fallback that
|
||||||
|
* leaked into the write path would corrupt the baseline every other branch
|
||||||
|
* depends on. Resolved once per run by the plugin — see
|
||||||
|
* {@see self::setFallbackBranch()} — because the git calls it takes are not
|
||||||
|
* free and the read path runs per test.
|
||||||
|
*/
|
||||||
|
private string $fallbackBranch = 'main';
|
||||||
|
|
||||||
private readonly string $projectRoot;
|
private readonly string $projectRoot;
|
||||||
|
|
||||||
/** @var array<string, true>|null */
|
/** @var array<string, true>|null */
|
||||||
@@ -149,6 +160,13 @@ final class Graph
|
|||||||
*/
|
*/
|
||||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||||
{
|
{
|
||||||
|
// With no recorded table usage at all, table intersection can never
|
||||||
|
// select anything — route every migration change through the
|
||||||
|
// watch-pattern fallback instead of silently skipping tests.
|
||||||
|
if ($this->testTables === []) {
|
||||||
|
return $migrationPaths;
|
||||||
|
}
|
||||||
|
|
||||||
$changedTables = [];
|
$changedTables = [];
|
||||||
$unparseable = [];
|
$unparseable = [];
|
||||||
|
|
||||||
@@ -446,13 +464,14 @@ final class Graph
|
|||||||
|
|
||||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||||
|
|
||||||
|
// Only a walk that actually selected tests counts as handled — a
|
||||||
|
// component whose usage the static walk missed must still reach
|
||||||
|
// the watch-pattern fallback instead of being silently swallowed.
|
||||||
if ($bladeAffected !== []) {
|
if ($bladeAffected !== []) {
|
||||||
foreach ($bladeAffected as $testFile) {
|
foreach ($bladeAffected as $testFile) {
|
||||||
$affectedSet[$testFile] = true;
|
$affectedSet[$testFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$staticallyHandled[$rel] = true;
|
|
||||||
} elseif ($this->isBladeComponentPath($rel)) {
|
|
||||||
$staticallyHandled[$rel] = true;
|
$staticallyHandled[$rel] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,7 +587,12 @@ final class Graph
|
|||||||
return $this->fingerprint;
|
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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -603,7 +627,7 @@ final class Graph
|
|||||||
$this->baselines[$branch]['results'][$testId] = $entry;
|
$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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -614,7 +638,18 @@ final class Graph
|
|||||||
return $baseline['results'][$testId]['assertions'];
|
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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -641,7 +676,7 @@ final class Graph
|
|||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @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);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
$files = [];
|
$files = [];
|
||||||
@@ -669,7 +704,16 @@ final class Graph
|
|||||||
return array_keys($files);
|
return array_keys($files);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool
|
/**
|
||||||
|
* Whether any cached result due a re-run points at a test file that is not
|
||||||
|
* on disk — deleted, or never locatable in the first place (`eval()`'d code,
|
||||||
|
* a path outside the project).
|
||||||
|
*
|
||||||
|
* A filtered run cannot honour such an entry: it would select a file that
|
||||||
|
* collects no tests, so the run reports green without ever re-running the
|
||||||
|
* failure — and does so again on every subsequent invocation.
|
||||||
|
*/
|
||||||
|
public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool
|
||||||
{
|
{
|
||||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||||
|
|
||||||
@@ -680,7 +724,16 @@ final class Graph
|
|||||||
|
|
||||||
$file = $result['file'] ?? null;
|
$file = $result['file'] ?? null;
|
||||||
|
|
||||||
if ($file === null || $file === '' || $this->relative($file) === null) {
|
if ($file === null || $file === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rel = $this->relative($file);
|
||||||
|
|
||||||
|
// Results are stored relative, so `relative()` answers "is this
|
||||||
|
// inside the project" without ever touching the filesystem. The
|
||||||
|
// stat is what tells a deleted test file apart from a live one.
|
||||||
|
if ($rel === null || ! is_file($this->projectRoot.'/'.$rel)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,8 +743,16 @@ final class Graph
|
|||||||
|
|
||||||
private function shouldRerun(int $status): bool
|
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()) {
|
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -763,7 +824,7 @@ final class Graph
|
|||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @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 $this->baselineFor($branch, $fallbackBranch)['tree'];
|
||||||
}
|
}
|
||||||
@@ -771,8 +832,10 @@ final class Graph
|
|||||||
/**
|
/**
|
||||||
* @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>, 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
|
||||||
{
|
{
|
||||||
|
$fallbackBranch ??= $this->fallbackBranch;
|
||||||
|
|
||||||
if (isset($this->baselines[$branch])) {
|
if (isset($this->baselines[$branch])) {
|
||||||
return $this->baselines[$branch];
|
return $this->baselines[$branch];
|
||||||
}
|
}
|
||||||
@@ -793,8 +856,15 @@ final class Graph
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<int, string>> $testToFiles
|
* @param array<string, array<int, string>> $testToFiles
|
||||||
|
* @param bool $keepExisting Leave already-recorded edge sets alone. For runs
|
||||||
|
* whose edges are piggybacked off a PHPUnit coverage
|
||||||
|
* session: that data is scoped by `<source>`, so it
|
||||||
|
* can only ever be narrower than what the TIA
|
||||||
|
* recorder sees — it never contains the test's own
|
||||||
|
* file, for one — and a narrower edge set silently
|
||||||
|
* stops selecting the tests it used to select.
|
||||||
*/
|
*/
|
||||||
public function replaceEdges(array $testToFiles): void
|
public function replaceEdges(array $testToFiles, bool $keepExisting = false): void
|
||||||
{
|
{
|
||||||
foreach ($testToFiles as $testFile => $sources) {
|
foreach ($testToFiles as $testFile => $sources) {
|
||||||
$testRel = $this->relative($testFile);
|
$testRel = $this->relative($testFile);
|
||||||
@@ -803,6 +873,12 @@ final class Graph
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty set means "known, covers nothing", so piggyback data is
|
||||||
|
// still an improvement there — only a populated set is protected.
|
||||||
|
if ($keepExisting && ($this->edges[$testRel] ?? []) !== []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->edges[$testRel] = [];
|
$this->edges[$testRel] = [];
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
foreach ($sources as $source) {
|
||||||
@@ -813,6 +889,38 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark test files that executed under a recorded coverage session as "known",
|
||||||
|
* seeding an empty edge set for any that produced zero project-source edges.
|
||||||
|
*
|
||||||
|
* Without this, a test that covers no application source (e.g. a pure unit
|
||||||
|
* test asserting on language primitives) never becomes an edge key, so
|
||||||
|
* {@see self::knowsTest()} reports it as unknown and it re-runs on every TIA
|
||||||
|
* run. Recording it with an empty edge set lets it be replayed/skipped while
|
||||||
|
* unchanged; it is still re-run whenever its own file changes, via
|
||||||
|
* {@see self::applyTestFileChanges()}.
|
||||||
|
*
|
||||||
|
* Must only be called from the recording path, where coverage was actually
|
||||||
|
* collected — otherwise a missing edge set could mean "coverage was off",
|
||||||
|
* not "genuinely covered nothing".
|
||||||
|
*
|
||||||
|
* @param array<int, string> $testFiles Absolute or project-relative test file paths.
|
||||||
|
*/
|
||||||
|
public function markKnownTestFiles(array $testFiles): void
|
||||||
|
{
|
||||||
|
foreach ($testFiles as $testFile) {
|
||||||
|
$rel = $this->relative($testFile);
|
||||||
|
|
||||||
|
if ($rel === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($this->edges[$rel])) {
|
||||||
|
$this->edges[$rel] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<int, string>> $testToTables
|
* @param array<string, array<int, string>> $testToTables
|
||||||
*/
|
*/
|
||||||
@@ -1217,7 +1325,21 @@ final class Graph
|
|||||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||||
$name = str_replace('/', '.', $tail);
|
$name = str_replace('/', '.', $tail);
|
||||||
|
|
||||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
if ($name === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [$name, str_replace('_', '-', $name)];
|
||||||
|
|
||||||
|
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||||
|
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||||
|
$base = substr($name, 0, -strlen('.index'));
|
||||||
|
|
||||||
|
$names[] = $base;
|
||||||
|
$names[] = str_replace('_', '-', $base);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($names));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
@@ -1289,13 +1411,7 @@ final class Graph
|
|||||||
/** @param array<string, array<int, string>> $edges */
|
/** @param array<string, array<int, string>> $edges */
|
||||||
private function anyTestUses(array $edges, string $component): bool
|
private function anyTestUses(array $edges, string $component): bool
|
||||||
{
|
{
|
||||||
foreach ($edges as $components) {
|
return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
|
||||||
if (in_array($component, $components, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pruneMissingTests(): void
|
public function pruneMissingTests(): void
|
||||||
@@ -1366,6 +1482,35 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branches a recorded graph holds baselines for, read straight from the
|
||||||
|
* encoded form.
|
||||||
|
*
|
||||||
|
* Answerable before the graph is hydrated because the default branch has to
|
||||||
|
* be resolved first: the fallback is what every hydrated graph reads its
|
||||||
|
* baselines through.
|
||||||
|
*
|
||||||
|
* @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
|
public static function decode(string $json, string $projectRoot): ?self
|
||||||
{
|
{
|
||||||
$data = json_decode($json, true);
|
$data = json_decode($json, true);
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ final class JsModuleGraph
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||||
|
|
||||||
|
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,12 +388,6 @@ final class JsModuleGraph
|
|||||||
|
|
||||||
private static function hasViteConfig(string $projectRoot): bool
|
private static function hasViteConfig(string $projectRoot): bool
|
||||||
{
|
{
|
||||||
foreach (self::VITE_CONFIG_NAMES as $name) {
|
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
|
||||||
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia\Lockfiles;
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Contracts\Lockfile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final readonly class PackageLock implements Lockfile
|
||||||
|
{
|
||||||
|
public function applies(string $filename): bool
|
||||||
|
{
|
||||||
|
return $filename === 'package-lock.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fingerprint(string $contents): ?string
|
||||||
|
{
|
||||||
|
$data = json_decode($contents, true);
|
||||||
|
|
||||||
|
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packages = $data['packages'];
|
||||||
|
|
||||||
|
$platformPaths = [];
|
||||||
|
|
||||||
|
foreach ($packages as $path => $meta) {
|
||||||
|
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
|
||||||
|
$platformPaths[] = $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($packages as $path => $meta) {
|
||||||
|
if (! is_string($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($meta)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->isPlatformSpecific($meta)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$version = $meta['version'] ?? null;
|
||||||
|
|
||||||
|
$entries[$path] = is_string($version) ? $version : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($entries);
|
||||||
|
|
||||||
|
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
return $encoded === false ? null : hash('xxh128', $encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function isPlatformSpecific(array $meta): bool
|
||||||
|
{
|
||||||
|
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $platformPaths
|
||||||
|
*/
|
||||||
|
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
|
||||||
|
{
|
||||||
|
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ final class Recorder
|
|||||||
|
|
||||||
private bool $active = false;
|
private bool $active = false;
|
||||||
|
|
||||||
|
private bool $captureCoverage = false;
|
||||||
|
|
||||||
private bool $driverChecked = false;
|
private bool $driverChecked = false;
|
||||||
|
|
||||||
private bool $driverAvailable = false;
|
private bool $driverAvailable = false;
|
||||||
@@ -43,6 +45,17 @@ final class Recorder
|
|||||||
private ?SourceScope $sourceScope = null;
|
private ?SourceScope $sourceScope = null;
|
||||||
|
|
||||||
public function activate(): void
|
public function activate(): void
|
||||||
|
{
|
||||||
|
$this->active = true;
|
||||||
|
$this->captureCoverage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable per-test link tracking (tables, Inertia components, database
|
||||||
|
* usage, rendered views) without driving pcov/xdebug — for runs where
|
||||||
|
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
||||||
|
*/
|
||||||
|
public function activateLinkTracking(): void
|
||||||
{
|
{
|
||||||
$this->active = true;
|
$this->active = true;
|
||||||
}
|
}
|
||||||
@@ -55,7 +68,7 @@ final class Recorder
|
|||||||
public function driverAvailable(): bool
|
public function driverAvailable(): bool
|
||||||
{
|
{
|
||||||
if (! $this->driverChecked) {
|
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->driver = 'pcov';
|
||||||
$this->driverAvailable = true;
|
$this->driverAvailable = true;
|
||||||
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||||
@@ -75,7 +88,11 @@ final class Recorder
|
|||||||
|
|
||||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||||
{
|
{
|
||||||
if (! $this->active || ! $this->driverAvailable()) {
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +112,10 @@ final class Recorder
|
|||||||
$this->perTestUsesDatabase[$file] = true;
|
$this->perTestUsesDatabase[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->captureCoverage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
\pcov\start();
|
\pcov\start();
|
||||||
@@ -107,7 +128,13 @@ final class Recorder
|
|||||||
|
|
||||||
public function endTest(): void
|
public function endTest(): void
|
||||||
{
|
{
|
||||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
if (! $this->active || $this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||||
|
$this->currentTestFile = null;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +159,15 @@ final class Recorder
|
|||||||
$data = \xdebug_get_code_coverage();
|
$data = \xdebug_get_code_coverage();
|
||||||
\xdebug_stop_code_coverage(true);
|
\xdebug_stop_code_coverage(true);
|
||||||
|
|
||||||
$coveredFiles = array_keys($data);
|
$scope = $this->sourceScope();
|
||||||
|
|
||||||
|
foreach (array_keys($data) as $file) {
|
||||||
|
if (! $scope->contains($file)) {
|
||||||
|
unset($data[$file]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($coveredFiles as $sourceFile) {
|
foreach ($coveredFiles as $sourceFile) {
|
||||||
@@ -351,5 +386,6 @@ final class Recorder
|
|||||||
$this->classUsesDatabaseCache = [];
|
$this->classUsesDatabaseCache = [];
|
||||||
$this->sourceScope = null;
|
$this->sourceScope = null;
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
|
$this->captureCoverage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
|
|||||||
*/
|
*/
|
||||||
final class TableExtractor
|
final class TableExtractor
|
||||||
{
|
{
|
||||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single (optionally quoted) identifier segment.
|
||||||
|
*/
|
||||||
|
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string> Sorted, deduped table names referenced by the
|
* @return list<string> Sorted, deduped table names referenced by the
|
||||||
@@ -22,22 +27,15 @@ final class TableExtractor
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||||
|
|
||||||
$matched = false;
|
|
||||||
foreach (self::DML_PREFIXES as $dml) {
|
|
||||||
if (str_starts_with($prefix, $dml)) {
|
|
||||||
$matched = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $matched) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||||
|
|
||||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||||
return [];
|
return [];
|
||||||
@@ -45,14 +43,9 @@ final class TableExtractor
|
|||||||
|
|
||||||
$tables = [];
|
$tables = [];
|
||||||
|
|
||||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
foreach ($matches[1] as $qualified) {
|
||||||
$name = $matches[1][$i] !== ''
|
$name = self::unqualified($qualified);
|
||||||
? $matches[1][$i]
|
|
||||||
: ($matches[2][$i] !== ''
|
|
||||||
? $matches[2][$i]
|
|
||||||
: ($matches[3][$i] !== ''
|
|
||||||
? $matches[3][$i]
|
|
||||||
: $matches[4][$i]));
|
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -63,7 +56,7 @@ final class TableExtractor
|
|||||||
$tables[strtolower($name)] = true;
|
$tables[strtolower($name)] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$out = array_keys($tables);
|
$out = array_map(strval(...), array_keys($tables));
|
||||||
sort($out);
|
sort($out);
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
@@ -80,51 +73,84 @@ final class TableExtractor
|
|||||||
|
|
||||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||||
foreach ($matches[1] as $i => $primary) {
|
foreach ($matches[1] as $i => $primary) {
|
||||||
$tables[strtolower($primary)] = true;
|
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||||
|
|
||||||
$secondary = $matches[2][$i] ?? '';
|
$secondary = $matches[2][$i] ?? '';
|
||||||
if ($secondary !== '') {
|
if ($secondary !== '') {
|
||||||
$tables[strtolower($secondary)] = true;
|
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||||
|
|
||||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
$sqlPatterns = [
|
||||||
foreach ($matches[1] as $primary) {
|
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
||||||
$lower = strtolower($primary);
|
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
||||||
if (! self::isSchemaMeta($lower)) {
|
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
||||||
$tables[$lower] = true;
|
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$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*\)/',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($dmlPatterns as $pattern) {
|
foreach ($sqlPatterns as $pattern) {
|
||||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach ($matches[1] as $name) {
|
foreach ($matches[1] as $name) {
|
||||||
$lower = strtolower($name);
|
$lower = strtolower(self::unqualified($name));
|
||||||
if (! self::isSchemaMeta($lower)) {
|
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||||
$tables[$lower] = true;
|
$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);
|
sort($out);
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table segment of a possibly schema-qualified identifier chain,
|
||||||
|
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
||||||
|
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
||||||
|
*/
|
||||||
|
private static function unqualified(string $qualified): string
|
||||||
|
{
|
||||||
|
$name = '';
|
||||||
|
|
||||||
|
foreach (explode('.', $qualified) as $segment) {
|
||||||
|
$segment = trim($segment, " \t\n\r\"`[]");
|
||||||
|
|
||||||
|
if ($segment === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isSchemaMeta($segment)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lastDottedSegment(string $name): string
|
||||||
|
{
|
||||||
|
$position = strrpos($name, '.');
|
||||||
|
|
||||||
|
return $position === false ? $name : substr($name, $position + 1);
|
||||||
|
}
|
||||||
|
|
||||||
private static function isSchemaMeta(string $name): bool
|
private static function isSchemaMeta(string $name): bool
|
||||||
{
|
{
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ final readonly class TestPaths
|
|||||||
$directories[] = $rel;
|
$directories[] = $rel;
|
||||||
}
|
}
|
||||||
|
|
||||||
$suffix = $directory->suffix();
|
$suffixes[] = $directory->suffix();
|
||||||
|
|
||||||
if ($suffix !== '') {
|
|
||||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($suite->files() as $file) {
|
foreach ($suite->files() as $file) {
|
||||||
@@ -63,7 +59,7 @@ final readonly class TestPaths
|
|||||||
|
|
||||||
if ($suffixes === []) {
|
if ($suffixes === []) {
|
||||||
foreach ($configuration->testSuffixes() as $suffix) {
|
foreach ($configuration->testSuffixes() as $suffix) {
|
||||||
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
|
$suffixes[] = $suffix;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
@@ -94,15 +90,7 @@ final readonly class TestPaths
|
|||||||
if (in_array($relativePath, $this->files, true)) {
|
if (in_array($relativePath, $this->files, true)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
|
||||||
$matchesSuffix = false;
|
|
||||||
foreach ($this->suffixes as $suffix) {
|
|
||||||
if (str_ends_with($relativePath, $suffix)) {
|
|
||||||
$matchesSuffix = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $matchesSuffix) {
|
if (! $matchesSuffix) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ final class WatchPatterns
|
|||||||
|
|
||||||
private bool $baselined = false;
|
private bool $baselined = false;
|
||||||
|
|
||||||
|
private ?string $defaultBranch = null;
|
||||||
|
|
||||||
public function useDefaults(string $projectRoot): void
|
public function useDefaults(string $projectRoot): void
|
||||||
{
|
{
|
||||||
$testPath = TestSuite::getInstance()->testPath;
|
$testPath = TestSuite::getInstance()->testPath;
|
||||||
@@ -177,6 +179,16 @@ final class WatchPatterns
|
|||||||
return $this->baselined;
|
return $this->baselined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setDefaultBranch(string $branch): void
|
||||||
|
{
|
||||||
|
$this->defaultBranch = $branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultBranch(): ?string
|
||||||
|
{
|
||||||
|
return $this->defaultBranch;
|
||||||
|
}
|
||||||
|
|
||||||
public function reset(): void
|
public function reset(): void
|
||||||
{
|
{
|
||||||
$this->patterns = [];
|
$this->patterns = [];
|
||||||
@@ -185,6 +197,7 @@ final class WatchPatterns
|
|||||||
$this->locally = false;
|
$this->locally = false;
|
||||||
$this->filtered = false;
|
$this->filtered = false;
|
||||||
$this->baselined = false;
|
$this->baselined = false;
|
||||||
|
$this->defaultBranch = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function keyMatches(string $key, string $file): bool
|
private function keyMatches(string $key, string $file): bool
|
||||||
@@ -253,35 +266,17 @@ final class WatchPatterns
|
|||||||
|
|
||||||
private function patternTargetsDotfiles(string $pattern): bool
|
private function patternTargetsDotfiles(string $pattern): bool
|
||||||
{
|
{
|
||||||
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
|
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||||
if ($segment !== '' && $segment[0] === '.') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function touchesVcs(string $file): bool
|
private function touchesVcs(string $file): bool
|
||||||
{
|
{
|
||||||
foreach (explode('/', $file) as $segment) {
|
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
|
||||||
if (in_array($segment, self::VCS_DIRS, true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function touchesDotfile(string $file): bool
|
private function touchesDotfile(string $file): bool
|
||||||
{
|
{
|
||||||
foreach (explode('/', $file) as $segment) {
|
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
|
||||||
if ($segment !== '' && $segment[0] === '.') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function excludeMatches(string $exclude, string $file): bool
|
private function excludeMatches(string $exclude, string $file): bool
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ final class XdebugRestarter implements Restarter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(new XdebugHandler('pest'))->check();
|
new XdebugHandler('pest')->check();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function xdebugIsCoverageOnly(): bool
|
private function xdebugIsCoverageOnly(): bool
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
|
|||||||
|
|
||||||
if ($test instanceof TestMethod) {
|
if ($test instanceof TestMethod) {
|
||||||
$this->collector->recordAssertions(
|
$this->collector->recordAssertions(
|
||||||
$test->className().'::'.$test->methodName(),
|
$test->id(),
|
||||||
$event->numberOfAssertionsPerformed(),
|
$event->numberOfAssertionsPerformed(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparationStartedS
|
|||||||
$test = $event->test();
|
$test = $event->test();
|
||||||
|
|
||||||
if ($test instanceof TestMethod) {
|
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\CodeCoverage;
|
||||||
use SebastianBergmann\CodeCoverage\Node\Directory;
|
use SebastianBergmann\CodeCoverage\Node\Directory;
|
||||||
use SebastianBergmann\CodeCoverage\Node\File;
|
use SebastianBergmann\CodeCoverage\Node\File;
|
||||||
|
use SebastianBergmann\CodeCoverage\Report\Facade;
|
||||||
use SebastianBergmann\Environment\Runtime;
|
use SebastianBergmann\Environment\Runtime;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
@@ -95,10 +96,18 @@ final class Coverage
|
|||||||
$codeCoverage = require $reportPath;
|
$codeCoverage = require $reportPath;
|
||||||
unlink($reportPath);
|
unlink($reportPath);
|
||||||
|
|
||||||
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
|
// @phpstan-ignore-next-line
|
||||||
|
if (is_array($codeCoverage)) {
|
||||||
|
$facade = Facade::fromSerializedData($codeCoverage);
|
||||||
|
|
||||||
/** @var Directory<File|Directory> $report */
|
/** @var Directory<File|Directory> $report */
|
||||||
$report = $codeCoverage->getReport();
|
$report = (fn (): Directory => $this->report)->call($facade);
|
||||||
|
} else {
|
||||||
|
/** @var Directory<File|Directory> $report */
|
||||||
|
$report = $codeCoverage->getReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCoverage = $report->percentageOfExecutedLines();
|
||||||
|
|
||||||
foreach ($report->getIterator() as $file) {
|
foreach ($report->getIterator() as $file) {
|
||||||
if (! $file instanceof 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));
|
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports a value into a full single-line string without truncation.
|
||||||
|
*/
|
||||||
|
public function export(mixed $value): string
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'#\\\n\s*#' => '',
|
||||||
|
'# Object \(\.{3}\)#' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ final class HigherOrderMessage
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->hasHigherOrderCallable()) {
|
if ($this->hasHigherOrderCallable()) {
|
||||||
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
|
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
|
|||||||
*/
|
*/
|
||||||
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
|
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
|
||||||
{
|
{
|
||||||
$this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition);
|
$this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments)->when($condition);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ final class HigherOrderTapProxy
|
|||||||
return $this->target->{$property};
|
return $this->target->{$property};
|
||||||
}
|
}
|
||||||
|
|
||||||
$className = (new ReflectionClass($this->target))->getName();
|
$className = new ReflectionClass($this->target)->getName();
|
||||||
|
|
||||||
if (str_starts_with($className, 'P\\')) {
|
if (str_starts_with($className, 'P\\')) {
|
||||||
$className = substr($className, 2);
|
$className = substr($className, 2);
|
||||||
@@ -60,7 +60,7 @@ final class HigherOrderTapProxy
|
|||||||
$filename = Backtrace::file();
|
$filename = Backtrace::file();
|
||||||
$line = Backtrace::line();
|
$line = Backtrace::line();
|
||||||
|
|
||||||
return (new HigherOrderMessage($filename, $line, $methodName, $arguments))
|
return new HigherOrderMessage($filename, $line, $methodName, $arguments)
|
||||||
->call($this->target);
|
->call($this->target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ final class Reflection
|
|||||||
*/
|
*/
|
||||||
public static function getFunctionArguments(Closure $function): array
|
public static function getFunctionArguments(Closure $function): array
|
||||||
{
|
{
|
||||||
$parameters = (new ReflectionFunction($function))->getParameters();
|
$parameters = new ReflectionFunction($function)->getParameters();
|
||||||
$arguments = [];
|
$arguments = [];
|
||||||
|
|
||||||
foreach ($parameters as $parameter) {
|
foreach ($parameters as $parameter) {
|
||||||
@@ -207,7 +207,7 @@ final class Reflection
|
|||||||
|
|
||||||
public static function getFunctionVariable(Closure $function, string $key): mixed
|
public static function getFunctionVariable(Closure $function, string $key): mixed
|
||||||
{
|
{
|
||||||
return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null;
|
return new ReflectionFunction($function)->getStaticVariables()[$key] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ use NunoMaduro\Collision\Exceptions\TestOutcome;
|
|||||||
use PHPUnit\Event\Code\TestDoxBuilder;
|
use PHPUnit\Event\Code\TestDoxBuilder;
|
||||||
use PHPUnit\Event\Code\TestMethod;
|
use PHPUnit\Event\Code\TestMethod;
|
||||||
use PHPUnit\Event\Code\ThrowableBuilder;
|
use PHPUnit\Event\Code\ThrowableBuilder;
|
||||||
|
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||||
|
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||||
|
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||||
|
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||||
use PHPUnit\Event\Test\Errored;
|
use PHPUnit\Event\Test\Errored;
|
||||||
use PHPUnit\Event\Test\Failed;
|
use PHPUnit\Event\Test\Failed;
|
||||||
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
|
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
|
||||||
@@ -35,8 +39,7 @@ final class StateGenerator
|
|||||||
$testResultEvent->throwable()
|
$testResultEvent->throwable()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
// @phpstan-ignore-next-line
|
$this->addClassLevelEvent($state, $testResultEvent);
|
||||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +51,7 @@ final class StateGenerator
|
|||||||
$testResultEvent->throwable()
|
$testResultEvent->throwable()
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
// @phpstan-ignore-next-line
|
$this->addClassLevelEvent($state, $testResultEvent);
|
||||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,36 @@ final class StateGenerator
|
|||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given class-level "hook" failure to the state. Collision's
|
||||||
|
* `fromBeforeFirstTestMethodErrored` only accepts `BeforeFirstTestMethodErrored`
|
||||||
|
* events, so the remaining class-level events get a synthesized test method.
|
||||||
|
*/
|
||||||
|
private function addClassLevelEvent(State $state, AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed $event): void
|
||||||
|
{
|
||||||
|
if ($event instanceof BeforeFirstTestMethodErrored) {
|
||||||
|
$state->add(TestResult::fromBeforeFirstTestMethodErrored($event));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$methodName = $event instanceof BeforeFirstTestMethodFailed ? 'beforeAll' : 'afterAll';
|
||||||
|
|
||||||
|
$state->add(TestResult::fromPestParallelTestCase(
|
||||||
|
new TestMethod(
|
||||||
|
$event->testClassName(), // @phpstan-ignore-line
|
||||||
|
$methodName,
|
||||||
|
'', // @phpstan-ignore-line
|
||||||
|
1,
|
||||||
|
TestDoxBuilder::fromClassNameAndMethodName($event->testClassName(), $methodName), // @phpstan-ignore-line
|
||||||
|
MetadataCollection::fromArray([]),
|
||||||
|
TestDataCollection::fromArray([])
|
||||||
|
),
|
||||||
|
TestResult::FAIL,
|
||||||
|
$event->throwable()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
|
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,6 +17,33 @@ final class Str
|
|||||||
|
|
||||||
private const string PREFIX = '__pest_evaluable_';
|
private const string PREFIX = '__pest_evaluable_';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of names PHP reserves, and therefore refuses, as class names.
|
||||||
|
*
|
||||||
|
* @see https://github.com/php/php-src/blob/master/Zend/zend_compile.c
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
private const array RESERVED_CLASS_NAMES = [
|
||||||
|
'array',
|
||||||
|
'bool',
|
||||||
|
'callable',
|
||||||
|
'false',
|
||||||
|
'float',
|
||||||
|
'int',
|
||||||
|
'iterable',
|
||||||
|
'mixed',
|
||||||
|
'never',
|
||||||
|
'null',
|
||||||
|
'object',
|
||||||
|
'parent',
|
||||||
|
'self',
|
||||||
|
'static',
|
||||||
|
'string',
|
||||||
|
'true',
|
||||||
|
'void',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
|
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
|
||||||
* string value.
|
* string value.
|
||||||
@@ -64,6 +91,35 @@ final class Str
|
|||||||
return (string) preg_replace('/[^a-zA-Z0-9_\x80-\xff]/', '_', $code);
|
return (string) preg_replace('/[^a-zA-Z0-9_\x80-\xff]/', '_', $code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the given name is a valid PHP identifier, and therefore may
|
||||||
|
* be used as a single namespace name.
|
||||||
|
*/
|
||||||
|
public static function isValidIdentifier(string $name): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the given name may be declared as a class name by an `eval`.
|
||||||
|
*/
|
||||||
|
public static function isValidClassName(string $name): bool
|
||||||
|
{
|
||||||
|
if (! self::isValidIdentifier($name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array(strtolower($name), self::RESERVED_CLASS_NAMES, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = token_get_all(sprintf('<?php %s;', $name));
|
||||||
|
|
||||||
|
// Anything the lexer sees as a keyword, like `list` or `match`, may not
|
||||||
|
// be used as a class name.
|
||||||
|
return is_array($tokens[1] ?? null) && $tokens[1][0] === T_STRING;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the portion of a string before the last occurrence of a given value.
|
* Get the portion of a string before the last occurrence of a given value.
|
||||||
*/
|
*/
|
||||||
@@ -98,6 +154,14 @@ final class Str
|
|||||||
return preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0;
|
return preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a given value is a valid ULID.
|
||||||
|
*/
|
||||||
|
public static function isUlid(string $value): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', $value) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a describe block as `$describeDescription` → `$testDescription` format.
|
* Creates a describe block as `$describeDescription` → `$testDescription` format.
|
||||||
*
|
*
|
||||||
@@ -110,6 +174,14 @@ final class Str
|
|||||||
return sprintf(str_repeat('`%s` → ', count($describeDescriptions)).'%s', ...$descriptionComponents);
|
return sprintf(str_repeat('`%s` → ', count($describeDescriptions)).'%s', ...$descriptionComponents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a given value is a valid email address.
|
||||||
|
*/
|
||||||
|
public static function isEmail(string $value): bool
|
||||||
|
{
|
||||||
|
return (bool) filter_var($value, FILTER_VALIDATE_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if a given value is a valid URL.
|
* Determine if a given value is a valid URL.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,18 +6,18 @@
|
|||||||
FAILED Tests\Fixtures\CollisionTest > error Exception
|
FAILED Tests\Fixtures\CollisionTest > error Exception
|
||||||
error
|
error
|
||||||
|
|
||||||
at tests/Fixtures/CollisionTest.php:4
|
at tests/Fixtures/CollisionTest.php:6
|
||||||
1▕ <?php
|
|
||||||
2▕
|
2▕
|
||||||
3▕ test('error', function () {
|
3▕ declare(strict_types=1);
|
||||||
➜ 4▕ throw new Exception('error');
|
4▕
|
||||||
5▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
5▕ test('error', function (): void {
|
||||||
6▕
|
➜ 6▕ throw new Exception('error');
|
||||||
7▕ test('success', function () {
|
7▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
||||||
8▕ expect(true)->toBeTrue();
|
8▕
|
||||||
9▕ })->skip(! isset($_SERVER['COLLISION_TEST']));
|
9▕ test('success', function (): void {
|
||||||
|
10▕ expect(true)->toBeTrue();
|
||||||
|
|
||||||
1 tests/Fixtures/CollisionTest.php:4
|
1 tests/Fixtures/CollisionTest.php:6
|
||||||
|
|
||||||
|
|
||||||
Tests: 1 failed, 1 passed (1 assertions)
|
Tests: 1 failed, 1 passed (1 assertions)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
Pest Testing Framework 4.7.4.
|
Pest Testing Framework 5.0.3.
|
||||||
|
|
||||||
USAGE: pest <file> [options]
|
USAGE: pest <file> [options]
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
--cache-directory [dir] ............................ Specify cache directory
|
--cache-directory [dir] ............................ Specify cache directory
|
||||||
--generate-configuration Generate configuration file with suggested settings
|
--generate-configuration Generate configuration file with suggested settings
|
||||||
--migrate-configuration ....... Migrate configuration file to current format
|
--migrate-configuration ....... Migrate configuration file to current format
|
||||||
|
--validate-configuration ................... Validate XML configuration file
|
||||||
--generate-baseline [file] .................... Generate baseline for issues
|
--generate-baseline [file] .................... Generate baseline for issues
|
||||||
--use-baseline [file] ........................ Use baseline to ignore issues
|
--use-baseline [file] ........................ Use baseline to ignore issues
|
||||||
--ignore-baseline ..................... Do not use baseline to ignore issues
|
--ignore-baseline ..................... Do not use baseline to ignore issues
|
||||||
@@ -40,11 +41,15 @@
|
|||||||
--uses [name] ..................... Only run tests that intend to use [name]
|
--uses [name] ..................... Only run tests that intend to use [name]
|
||||||
--requires-php-extension [name] Only run tests that require PHP extension [name]
|
--requires-php-extension [name] Only run tests that require PHP extension [name]
|
||||||
--list-test-files ................................ List available test files
|
--list-test-files ................................ List available test files
|
||||||
|
--list-test-ids ........................... List available tests as test IDs
|
||||||
--list-tests .......................................... List available tests
|
--list-tests .......................................... List available tests
|
||||||
--list-tests-xml [file] ................. List available tests in XML format
|
--list-tests-xml [file] ................. List available tests in XML format
|
||||||
--filter [pattern] ............................... Filter which tests to run
|
--filter [pattern] ............................... Filter which tests to run
|
||||||
--exclude-filter [pattern] .. Exclude tests for the specified filter pattern
|
--exclude-filter [pattern] .. Exclude tests for the specified filter pattern
|
||||||
--test-suffix [suffixes] Only search for test in files with specified suffix(es). Default: Test.php,.phpt
|
--test-suffix [suffixes] Only search for test in files with specified suffix(es). Default: Test.php,.phpt
|
||||||
|
--test-files-file [file] Only run test files listed in file (one file by line)
|
||||||
|
--run-test-id [test-id] Only run the test identified by the specified test ID
|
||||||
|
--test-id-filter-file [file] Only run tests listed by test ID in file (one test ID per line)
|
||||||
|
|
||||||
EXECUTION OPTIONS:
|
EXECUTION OPTIONS:
|
||||||
--parallel ........................................... Run tests in parallel
|
--parallel ........................................... Run tests in parallel
|
||||||
@@ -53,20 +58,21 @@
|
|||||||
--globals-backup ................. Backup and restore $GLOBALS for each test
|
--globals-backup ................. Backup and restore $GLOBALS for each test
|
||||||
--static-backup ......... Backup and restore static properties for each test
|
--static-backup ......... Backup and restore static properties for each test
|
||||||
--strict-coverage ................... Be strict about code coverage metadata
|
--strict-coverage ................... Be strict about code coverage metadata
|
||||||
|
--require-coverage-contribution Be strict about tests that do not contribute to code coverage
|
||||||
--strict-global-state .............. Be strict about changes to global state
|
--strict-global-state .............. Be strict about changes to global state
|
||||||
--disallow-test-output ................. Be strict about output during tests
|
--disallow-test-output ................. Be strict about output during tests
|
||||||
--enforce-time-limit ................. Enforce time limit based on test size
|
--enforce-time-limit ................. Enforce time limit based on test size
|
||||||
--default-time-limit [sec] Timeout in seconds for tests that have no declared size
|
--default-time-limit [sec] Timeout in seconds for tests that have no declared size
|
||||||
--do-not-report-useless-tests Do not report tests that do not test anything
|
--do-not-report-useless-tests Do not report tests that do not test anything
|
||||||
--stop-on-defect ... Stop after first error, failure, warning, or risky test
|
--stop-on-defect[=[n]] Stop after first (or n-th) error, failure, warning, or risky test
|
||||||
--stop-on-error ..................................... Stop after first error
|
--stop-on-error[=[n]] ..................... Stop after first (or n-th) error
|
||||||
--stop-on-failure ................................. Stop after first failure
|
--stop-on-failure[=[n]] ................. Stop after first (or n-th) failure
|
||||||
--stop-on-warning ................................. Stop after first warning
|
--stop-on-warning[=[n]] ................. Stop after first (or n-th) warning
|
||||||
--stop-on-risky ................................ Stop after first risky test
|
--stop-on-risky[=[n]] ................ Stop after first (or n-th) risky test
|
||||||
--stop-on-deprecation ... Stop after first test that triggered a deprecation
|
--stop-on-deprecation[=[n]] Stop after first (or n-th) test that triggered a deprecation
|
||||||
--stop-on-notice ............. Stop after first test that triggered a notice
|
--stop-on-notice[=[n]] Stop after first (or n-th) test that triggered a notice
|
||||||
--stop-on-skipped ............................ Stop after first skipped test
|
--stop-on-skipped[=[n]] ............ Stop after first (or n-th) skipped test
|
||||||
--stop-on-incomplete ...................... Stop after first incomplete test
|
--stop-on-incomplete[=[n]] ...... Stop after first (or n-th) incomplete test
|
||||||
--fail-on-empty-test-suite Signal failure using shell exit code when no tests were run
|
--fail-on-empty-test-suite Signal failure using shell exit code when no tests were run
|
||||||
--fail-on-warning Signal failure using shell exit code when a warning was triggered
|
--fail-on-warning Signal failure using shell exit code when a warning was triggered
|
||||||
--fail-on-risky Signal failure using shell exit code when a test was considered risky
|
--fail-on-risky Signal failure using shell exit code when a test was considered risky
|
||||||
@@ -90,7 +96,7 @@
|
|||||||
--do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete
|
--do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete
|
||||||
--cache-result ............................ Write test results to cache file
|
--cache-result ............................ Write test results to cache file
|
||||||
--do-not-cache-result .............. Do not write test results to cache file
|
--do-not-cache-result .............. Do not write test results to cache file
|
||||||
--order-by [order] Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size
|
--order-by [order] Run tests in order: default|defects|depends|duration-ascending|duration-descending|no-depends|random|reverse|size-ascending|size-descending
|
||||||
--resolve-dependencies ...................... Alias for "--order-by depends"
|
--resolve-dependencies ...................... Alias for "--order-by depends"
|
||||||
--ignore-dependencies .................... Alias for "--order-by no-depends"
|
--ignore-dependencies .................... Alias for "--order-by no-depends"
|
||||||
--random-order ............................... Alias for "--order-by random"
|
--random-order ............................... Alias for "--order-by random"
|
||||||
@@ -101,6 +107,7 @@
|
|||||||
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
|
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
|
||||||
--columns [n] ................. Number of columns to use for progress output
|
--columns [n] ................. Number of columns to use for progress output
|
||||||
--columns max ............ Use maximum number of columns for progress output
|
--columns max ............ Use maximum number of columns for progress output
|
||||||
|
--diff-context [n] Number of context lines shown around changes in diffs (default: 3)
|
||||||
--stderr ................................. Write to STDERR instead of STDOUT
|
--stderr ................................. Write to STDERR instead of STDOUT
|
||||||
--no-progress .................... Disable output of test execution progress
|
--no-progress .................... Disable output of test execution progress
|
||||||
--no-results ................................ Disable output of test results
|
--no-results ................................ Disable output of test results
|
||||||
@@ -115,6 +122,7 @@
|
|||||||
--display-warnings ......... Display details for warnings triggered by tests
|
--display-warnings ......... Display details for warnings triggered by tests
|
||||||
--display-all-issues ..... Display details for all issues that are triggered
|
--display-all-issues ..... Display details for all issues that are triggered
|
||||||
--reverse-list .............................. Print defects in reverse order
|
--reverse-list .............................. Print defects in reverse order
|
||||||
|
--compact ... Replace default progress and result output with compact format
|
||||||
--teamcity . Replace default progress and result output with TeamCity format
|
--teamcity . Replace default progress and result output with TeamCity format
|
||||||
--testdox ................ Replace default result output with TestDox format
|
--testdox ................ Replace default result output with TestDox format
|
||||||
--testdox-summary Repeat TestDox output for tests with errors, failures, or issues
|
--testdox-summary Repeat TestDox output for tests with errors, failures, or issues
|
||||||
@@ -125,12 +133,12 @@
|
|||||||
LOGGING OPTIONS:
|
LOGGING OPTIONS:
|
||||||
--log-junit [file] .......... Write test results in JUnit XML format to file
|
--log-junit [file] .......... Write test results in JUnit XML format to file
|
||||||
--log-otr [file] Write test results in Open Test Reporting XML format to file
|
--log-otr [file] Write test results in Open Test Reporting XML format to file
|
||||||
--include-git-information Include Git information in Open Test Reporting XML logfile
|
|
||||||
--log-teamcity [file] ........ Write test results in TeamCity format to file
|
--log-teamcity [file] ........ Write test results in TeamCity format to file
|
||||||
--testdox-html [file] .. Write test results in TestDox format (HTML) to file
|
--testdox-html [file] .. Write test results in TestDox format (HTML) to file
|
||||||
--testdox-text [file] Write test results in TestDox format (plain text) to file
|
--testdox-text [file] Write test results in TestDox format (plain text) to file
|
||||||
--log-events-text [file] ............... Stream events as plain text to file
|
--log-events-text [file] ............... Stream events as plain text to file
|
||||||
--log-events-verbose-text [file] Stream events as plain text with extended information to file
|
--log-events-verbose-text [file] Stream events as plain text with extended information to file
|
||||||
|
--include-git-information ..... Include Git information in supported formats
|
||||||
--no-logging ....... Ignore logging configured in the XML configuration file
|
--no-logging ....... Ignore logging configured in the XML configuration file
|
||||||
|
|
||||||
CODE COVERAGE OPTIONS:
|
CODE COVERAGE OPTIONS:
|
||||||
@@ -151,13 +159,25 @@
|
|||||||
--exclude-source-from-xml-coverage Exclude [source] element from code coverage report in XML format
|
--exclude-source-from-xml-coverage Exclude [source] element from code coverage report in XML format
|
||||||
--warm-coverage-cache ........................... Warm static analysis cache
|
--warm-coverage-cache ........................... Warm static analysis cache
|
||||||
--coverage-filter [dir] ........... Include [dir] in code coverage reporting
|
--coverage-filter [dir] ........... Include [dir] in code coverage reporting
|
||||||
--path-coverage .......... Report path coverage in addition to line coverage
|
--branch-coverage ...... Report branch coverage in addition to line coverage
|
||||||
|
--path-coverage ... Report path coverage in addition to line/branch coverage
|
||||||
--disable-coverage-ignore ...... Disable metadata for ignoring code coverage
|
--disable-coverage-ignore ...... Disable metadata for ignoring code coverage
|
||||||
|
--disable-coverage-targeting .. Disable metadata for code coverage targeting
|
||||||
--no-coverage Ignore code coverage reporting configured in the XML configuration file
|
--no-coverage Ignore code coverage reporting configured in the XML configuration file
|
||||||
|
|
||||||
AI OPTIONS:
|
AI OPTIONS:
|
||||||
--ai ..... Run a code snippet as a fully scaffolded test for AI verification
|
--ai ..... Run a code snippet as a fully scaffolded test for AI verification
|
||||||
|
|
||||||
|
TIA OPTIONS:
|
||||||
|
--tia Re-run only the tests affected by your changes, replaying the rest from cache
|
||||||
|
--no-tia ......................... Disable test impact analysis for this run
|
||||||
|
--tia --fresh .... Discard the recorded dependency graph and record it again
|
||||||
|
--tia --filtered ............ Narrow the run to the affected test files only
|
||||||
|
--tia --locally ......... Enable test impact analysis on local machines only
|
||||||
|
--tia --baselined Fetch the shared dependency graph recorded by the CI baseline
|
||||||
|
--tia --refetch ......... Force a fresh fetch of the shared dependency graph
|
||||||
|
--baseline Output to standard output the test impact analysis storage directory
|
||||||
|
|
||||||
MUTATION TESTING OPTIONS:
|
MUTATION TESTING OPTIONS:
|
||||||
--mutate .... Runs mutation testing, to understand the quality of your tests
|
--mutate .... Runs mutation testing, to understand the quality of your tests
|
||||||
--mutate --parallel ...................... Runs mutation testing in parallel
|
--mutate --parallel ...................... Runs mutation testing in parallel
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
|
|
||||||
Pest Testing Framework 4.7.4.
|
Pest Testing Framework 5.0.3.
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234']
|
##teamcity[testSuiteStarted name='Tests/Fixtures/Suites/Failure' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php' flowId='1234']
|
||||||
##teamcity[testCount count='8' flowId='1234']
|
##teamcity[testCount count='8' flowId='1234']
|
||||||
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
|
##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can fail with comparison' flowId='1234']
|
||||||
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
|
##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/Fixtures/Suites/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234']
|
||||||
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
|
##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can be ignored because of no assertions' flowId='1234']
|
||||||
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
|
##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
|
||||||
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
|
##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can be ignored because it is skipped' flowId='1234']
|
||||||
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
|
##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
|
||||||
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
|
##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it can fail' flowId='1234']
|
||||||
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234']
|
##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/Fixtures/Suites/Failure.php:18' flowId='1234']
|
||||||
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234']
|
##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it throws exception' flowId='1234']
|
||||||
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234']
|
##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/Fixtures/Suites/Failure.php:22' flowId='1234']
|
||||||
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
|
##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it is not done yet' flowId='1234']
|
||||||
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
|
##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::build this one.' flowId='1234']
|
||||||
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
|
##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234']
|
##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/Fixtures/Suites/Failure.php::it is passing' flowId='1234']
|
||||||
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it is passing' duration='100000' flowId='1234']
|
||||||
##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234']
|
##teamcity[testSuiteFinished name='Tests/Fixtures/Suites/Failure' flowId='1234']
|
||||||
|
|
||||||
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
[90mTests:[39m [31;1m3 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m,[39m[39m [39m[32;1m1 passed[39;22m[90m (3 assertions)[39m
|
||||||
[90mDuration:[39m [39m1.00s[39m
|
[90mDuration:[39m [39m1.00s[39m
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234']
|
##teamcity[testSuiteStarted name='Tests/Fixtures/Suites/SuccessOnly' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php' flowId='1234']
|
||||||
##teamcity[testCount count='4' flowId='1234']
|
##teamcity[testCount count='4' flowId='1234']
|
||||||
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
|
##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::it can pass with comparison' flowId='1234']
|
||||||
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
|
##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
|
||||||
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
|
##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can also pass' flowId='1234']
|
||||||
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
|
##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
|
||||||
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234']
|
##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can pass with dataset' flowId='1234']
|
||||||
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
|
##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234']
|
||||||
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
|
##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234']
|
||||||
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
|
##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234']
|
||||||
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
|
##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234']
|
||||||
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
|
##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/Fixtures/Suites/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234']
|
||||||
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
|
##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234']
|
||||||
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
|
##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234']
|
||||||
##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234']
|
##teamcity[testSuiteFinished name='Tests/Fixtures/Suites/SuccessOnly' flowId='1234']
|
||||||
|
|
||||||
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
[90mTests:[39m [32;1m4 passed[39;22m[90m (4 assertions)[39m
|
||||||
[90mDuration:[39m [39m1.00s[39m
|
[90mDuration:[39m [39m1.00s[39m
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
WARN Tests\Fixtures\CollisionTest
|
|
||||||
- error
|
|
||||||
- success
|
|
||||||
|
|
||||||
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
|
PASS Tests\Fixtures\DirectoryWithTests\ExampleTest
|
||||||
✓ it example 1
|
✓ it example 1
|
||||||
|
|
||||||
PASS Tests\Fixtures\ExampleTest
|
Tests: 1 passed (1 assertions)
|
||||||
✓ it example 2
|
|
||||||
|
|
||||||
WARN Tests\Fixtures\Inheritance\Base\ExampleTest
|
|
||||||
- example
|
|
||||||
|
|
||||||
PASS Tests\Fixtures\Inheritance\ExampleTest
|
|
||||||
✓ example
|
|
||||||
|
|
||||||
Tests: 3 skipped, 3 passed (3 assertions)
|
|
||||||
|
|||||||
@@ -136,6 +136,11 @@
|
|||||||
✓ describe()->with() preserves depends → first with (9)
|
✓ describe()->with() preserves depends → first with (9)
|
||||||
✓ describe()->with() preserves depends → second with (9)
|
✓ describe()->with() preserves depends → second with (9)
|
||||||
|
|
||||||
|
PASS Tests\Features\DatasetProviderErrors
|
||||||
|
✓ reports missing datasets as errors for a single file run
|
||||||
|
✓ reports missing datasets as errors alongside passing tests
|
||||||
|
✓ reports dataset closure exceptions as errors
|
||||||
|
|
||||||
PASS Tests\Features\DatasetsTests - 1 todo
|
PASS Tests\Features\DatasetsTests - 1 todo
|
||||||
✓ it throws exception if dataset does not exist
|
✓ it throws exception if dataset does not exist
|
||||||
✓ it throws exception if dataset already exist
|
✓ it throws exception if dataset already exist
|
||||||
@@ -293,8 +298,8 @@
|
|||||||
✓ it uses correct parent class
|
✓ it uses correct parent class
|
||||||
|
|
||||||
DEPR Tests\Features\Deprecated
|
DEPR Tests\Features\Deprecated
|
||||||
! deprecated → str_contains(): Passing null to parameter #1 ($haystack) of type string is deprecated // tests/Features/Deprecated.php:4
|
✓ deprecated
|
||||||
! user deprecated → Since foo 1.0: This is a deprecation description // tests/Features/Deprecated.php:10
|
! user deprecated → Since foo 1.0: This is a deprecation description // tests/Features/Deprecated.php:12
|
||||||
|
|
||||||
PASS Tests\Features\Describe - 5 todos
|
PASS Tests\Features\Describe - 5 todos
|
||||||
✓ before each
|
✓ before each
|
||||||
@@ -471,6 +476,16 @@
|
|||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeBase64
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid padding
|
||||||
|
✓ failures with padding only
|
||||||
|
✓ failures with malformed input
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeBetween
|
PASS Tests\Features\Expect\toBeBetween
|
||||||
@@ -517,6 +532,21 @@
|
|||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeDomain
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with leading dot
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeEmail
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ failures with default message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeEmpty
|
PASS Tests\Features\Expect\toBeEmpty
|
||||||
@@ -572,6 +602,24 @@
|
|||||||
✓ passes with strings
|
✓ passes with strings
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeHexadecimal
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid characters
|
||||||
|
✓ failures with zero-x prefix
|
||||||
|
✓ failures with empty string
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeHostname
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with trailing hyphen
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeIn
|
PASS Tests\Features\Expect\toBeIn
|
||||||
@@ -610,6 +658,13 @@
|
|||||||
✓ failure when the class is not invokable
|
✓ failure when the class is not invokable
|
||||||
✓ class is not invokable
|
✓ class is not invokable
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeIpAddress
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeIterable
|
PASS Tests\Features\Expect\toBeIterable
|
||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
@@ -654,6 +709,13 @@
|
|||||||
✓ pass
|
✓ pass
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeMacAddress
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with invalid type
|
||||||
|
✓ failures with custom message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeNAN
|
PASS Tests\Features\Expect\toBeNAN
|
||||||
@@ -752,6 +814,13 @@
|
|||||||
✓ passes as not truthy with ('0')
|
✓ passes as not truthy with ('0')
|
||||||
✓ failures
|
✓ failures
|
||||||
✓ failures with custom message
|
✓ failures with custom message
|
||||||
|
✓ not failures
|
||||||
|
|
||||||
|
PASS Tests\Features\Expect\toBeUlid
|
||||||
|
✓ failures with wrong type
|
||||||
|
✓ pass
|
||||||
|
✓ failures
|
||||||
|
✓ failures with message
|
||||||
✓ not failures
|
✓ not failures
|
||||||
|
|
||||||
PASS Tests\Features\Expect\toBeUppercase
|
PASS Tests\Features\Expect\toBeUppercase
|
||||||
@@ -1260,8 +1329,8 @@
|
|||||||
// This is another runtime note
|
// This is another runtime note
|
||||||
|
|
||||||
NOTI Tests\Features\Notices
|
NOTI Tests\Features\Notices
|
||||||
! notice → This is a notice description // tests/Features/Notices.php:4
|
! notice → This is a notice description // tests/Features/Notices.php:6
|
||||||
! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:11
|
! a "describe" group of tests → notice → This is a notice description // tests/Features/Notices.php:13
|
||||||
|
|
||||||
PASS Tests\Features\Pr
|
PASS Tests\Features\Pr
|
||||||
✓ it may be associated with an pr #1, #2
|
✓ it may be associated with an pr #1, #2
|
||||||
@@ -1496,6 +1565,37 @@
|
|||||||
✓ it allows performing no expectations without being risky
|
✓ it allows performing no expectations without being risky
|
||||||
✓ a "describe" group of tests → it allows performing no expectations without being risky
|
✓ a "describe" group of tests → it allows performing no expectations without being risky
|
||||||
|
|
||||||
|
PASS Tests\Features\Tia
|
||||||
|
✓ it does not run user hooks when replaying cached skipped and incomplete results
|
||||||
|
|
||||||
|
PASS Tests\Features\Tia\DefaultBranchReplay
|
||||||
|
✓ replays the default branch baseline on a new branch
|
||||||
|
✓ replays whatever the default branch is called with ('main')
|
||||||
|
✓ replays whatever the default branch is called with ('master')
|
||||||
|
✓ replays whatever the default branch is called with ('trunk')
|
||||||
|
✓ replays whatever the default branch is called with ('develop')
|
||||||
|
✓ replays on a second new branch too
|
||||||
|
✓ writes nothing on a second run on the same branch
|
||||||
|
✓ replays on a branch whose name contains slashes
|
||||||
|
✓ replays inside a worktree on a new branch
|
||||||
|
|
||||||
|
PASS Tests\Features\Tia\DefaultBranchResolution
|
||||||
|
✓ a declared default branch beats autodetection
|
||||||
|
✓ a declared default branch that does not exist degrades to a full run
|
||||||
|
✓ a repository with no remote is refused rather than silently re-run
|
||||||
|
✓ a declared default branch stands in for a missing remote
|
||||||
|
✓ tia still requires git
|
||||||
|
✓ a plain run outside a repository creates no baseline
|
||||||
|
✓ the default branch is resolved once per run, not once per test
|
||||||
|
|
||||||
|
PASS Tests\Features\Tia\DefaultBranchWriteTier
|
||||||
|
✓ narrows to the affected tests on a new branch
|
||||||
|
✓ filtered mode reads the fallback too
|
||||||
|
✓ filtered mode finds nothing to do on a clean green feature branch
|
||||||
|
✓ a detached HEAD replays without minting a branch key
|
||||||
|
✓ the branch that ran gets its own key and the default branch keeps its baseline
|
||||||
|
✓ the fallback reaches parallel workers
|
||||||
|
|
||||||
PASS Tests\Features\Ticket
|
PASS Tests\Features\Ticket
|
||||||
✓ it may be associated with an ticket #1, #2
|
✓ it may be associated with an ticket #1, #2
|
||||||
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
|
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
|
||||||
@@ -1696,6 +1796,8 @@
|
|||||||
PASS Tests\Unit\Expectations\OppositeExpectation
|
PASS Tests\Unit\Expectations\OppositeExpectation
|
||||||
✓ it throw expectation failed exception with string argument
|
✓ it throw expectation failed exception with string argument
|
||||||
✓ it throw expectation failed exception with array argument
|
✓ it throw expectation failed exception with array argument
|
||||||
|
✓ it does not truncate long string arguments in error message
|
||||||
|
✓ it does not truncate custom error message when using not()
|
||||||
|
|
||||||
PASS Tests\Unit\Overrides\ThrowableBuilder
|
PASS Tests\Unit\Overrides\ThrowableBuilder
|
||||||
✓ collision editor can be added to the stack trace
|
✓ collision editor can be added to the stack trace
|
||||||
@@ -1707,6 +1809,8 @@
|
|||||||
✓ method hasArgument with ('someValue', true)
|
✓ method hasArgument with ('someValue', true)
|
||||||
✓ method hasArgument with ('--a', false)
|
✓ method hasArgument with ('--a', false)
|
||||||
✓ method hasArgument with ('--undefined-argument', false)
|
✓ method hasArgument with ('--undefined-argument', false)
|
||||||
|
✓ popArgument preserves duplicate values when removing a missing argument
|
||||||
|
✓ popArgument preserves duplicate values when removing an existing argument
|
||||||
|
|
||||||
PASS Tests\Unit\Plugins\Environment
|
PASS Tests\Unit\Plugins\Environment
|
||||||
✓ environment is set to CI when --ci option is used
|
✓ environment is set to CI when --ci option is used
|
||||||
@@ -1715,6 +1819,52 @@
|
|||||||
PASS Tests\Unit\Plugins\Retry
|
PASS Tests\Unit\Plugins\Retry
|
||||||
✓ it orders by defects and stop on defects if when --retry is used
|
✓ it orders by defects and stop on defects if when --retry is used
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Shard
|
||||||
|
✓ getShard → it parses valid shard format with ('1/2', 1, 2)
|
||||||
|
✓ getShard → it parses valid shard format with ('2/2', 2, 2)
|
||||||
|
✓ getShard → it parses valid shard format with ('1/4', 1, 4)
|
||||||
|
✓ getShard → it parses valid shard format with ('4/4', 4, 4)
|
||||||
|
✓ getShard → it parses valid shard format with ('1/10', 1, 10)
|
||||||
|
✓ getShard → it parses valid shard format with ('10/10', 10, 10)
|
||||||
|
✓ getShard → it parses valid shard format with ('5/100', 5, 100)
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'invalid'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1/'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '/2'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'a/b'])
|
||||||
|
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1.5/2'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '0/2'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '1/0'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '3/2'])
|
||||||
|
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '5/4'])
|
||||||
|
✓ buildFilterArgument → it generates compact filter for single test
|
||||||
|
✓ buildFilterArgument → it generates compact filter for multiple tests with common prefix
|
||||||
|
✓ buildFilterArgument → it generates compact filter for tests with different namespaces
|
||||||
|
✓ buildFilterArgument → it returns empty string for empty test list
|
||||||
|
✓ buildFilterArgument → it generates compact filter for deeply nested namespaces
|
||||||
|
✓ buildFilterArgument → it handles mix of nested and flat namespaces
|
||||||
|
✓ ensureFilterLengthIsSafe → it accepts filter within length limit
|
||||||
|
✓ ensureFilterLengthIsSafe → it throws exception when filter exceeds default limit
|
||||||
|
✓ ensureFilterLengthIsSafe → it respects custom limit from environment variable
|
||||||
|
✓ ensureFilterLengthIsSafe → it accepts filter within custom limit
|
||||||
|
✓ handleArguments → it returns original arguments when shard option is not present
|
||||||
|
✓ handleArguments → it removes parallel arguments from test discovery
|
||||||
|
✓ parseListTestsOutput → it parses Tests\ namespaced classes from --list-tests output
|
||||||
|
✓ parseListTestsOutput → it deduplicates repeated class names from multiple test methods
|
||||||
|
✓ parseListTestsOutput → it returns an empty list for output with no matching lines
|
||||||
|
✓ parseListTestsOutput → it parses non-Tests namespaced classes
|
||||||
|
✓ parseListTestsOutput → it parses unnamespaced top-level classes
|
||||||
|
✓ parseListTestsOutput → it strips the P\ Pest prefix but keeps the rest of the FQCN
|
||||||
|
✓ parseListTestsOutput → it ignores junk lines that lack the " - …::" framing
|
||||||
|
✓ buildListTestsCommand → it builds the list-tests command with the forwarded --test-directory
|
||||||
|
✓ buildListTestsCommand → it strips --parallel and -p when building the list-tests command
|
||||||
|
✓ buildListTestsCommand → it forwards --test-directory even when input arguments include one
|
||||||
|
✓ buildListTestsCommand → it strips --processes=N when building the list-tests command
|
||||||
|
✓ buildListTestsCommand → it strips --processes N (space-separated) when building the list-tests command
|
||||||
|
✓ addOutput → it displays shard information after test execution
|
||||||
|
✓ addOutput → it uses singular form for single test file
|
||||||
|
✓ addOutput → it returns original exit code when shard is not set
|
||||||
|
|
||||||
PASS Tests\Unit\Plugins\Tia\ContentHash
|
PASS Tests\Unit\Plugins\Tia\ContentHash
|
||||||
✓ of() → it returns false when file does not exist
|
✓ of() → it returns false when file does not exist
|
||||||
✓ of() → it hashes an existing file
|
✓ of() → it hashes an existing file
|
||||||
@@ -1752,10 +1902,142 @@
|
|||||||
✓ output format → it returns a 32-character hex xxh128 hash
|
✓ output format → it returns a 32-character hex xxh128 hash
|
||||||
✓ output format → it returns a stable hash for empty content
|
✓ output format → it returns a stable hash for empty content
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\FileState
|
||||||
|
✓ keysWithPrefix() → it lists keys matching the prefix
|
||||||
|
✓ keysWithPrefix() → it ignores in-flight temporary files from concurrent writes
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\Graph
|
||||||
|
✓ shouldRerunStatus() → it re-runs failures and errors, replays successes
|
||||||
|
✓ applyMigrationChanges() → it selects tests whose recorded tables intersect the changed migration
|
||||||
|
✓ applyMigrationChanges() → it falls back to watch patterns when no table usage was recorded at all
|
||||||
|
✓ rerun tracking → it reruns cached failures via their file
|
||||||
|
✓ rerun tracking → it flags cached failures whose file is unknown
|
||||||
|
✓ applyBladeStaticChanges() → it maps an anonymous index component to the views that render it
|
||||||
|
✓ applyBladeStaticChanges() → it falls back to watch patterns for components with no matched usage
|
||||||
|
✓ markKnownTestFiles() → it makes a test file with no edges known
|
||||||
|
✓ markKnownTestFiles() → it does not clobber edges of an already-known test file
|
||||||
|
✓ markKnownTestFiles() → it ignores paths outside the project root
|
||||||
|
|
||||||
PASS Tests\Unit\Plugins\Tia\IsEnabledForRun
|
PASS Tests\Unit\Plugins\Tia\IsEnabledForRun
|
||||||
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
|
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
|
||||||
✓ still detects --tia when an integer argument is present
|
✓ still detects --tia when an integer argument is present
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock
|
||||||
|
✓ it applies only to package-lock.json
|
||||||
|
✓ it returns null for contents that are not an npm lockfile
|
||||||
|
✓ it is stable when the directory-derived top-level name changes
|
||||||
|
✓ it ignores platform-specific native binaries added or dropped per OS
|
||||||
|
✓ it ignores libc-constrained variants
|
||||||
|
✓ it ignores runtime deps bundled beneath a platform-specific package
|
||||||
|
✓ it detects a real dependency version change
|
||||||
|
✓ it detects an added or removed non-platform dependency
|
||||||
|
✓ it is independent of package ordering
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\Recorder
|
||||||
|
✓ activateLinkTracking() → it tracks tables without a coverage driver
|
||||||
|
✓ activateLinkTracking() → it tracks inertia components without a coverage driver
|
||||||
|
✓ activateLinkTracking() → it tracks linked sources across consecutive tests
|
||||||
|
✓ activateLinkTracking() → it records nothing while inactive
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\ResultKey
|
||||||
|
✓ it keys a result per dataset row rather than per method
|
||||||
|
✓ it records assertions against the same per-dataset key
|
||||||
|
✓ it leaves a test without a dataset keyed by class and method
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\TableExtractor
|
||||||
|
✓ fromSql() → it extracts tables from plain DML
|
||||||
|
✓ fromSql() → it extracts tables from joins
|
||||||
|
✓ fromSql() → it extracts tables from CTE queries
|
||||||
|
✓ fromSql() → it extracts tables from REPLACE INTO
|
||||||
|
✓ fromSql() → it records the table, not the schema, for qualified identifiers
|
||||||
|
✓ fromSql() → it handles quoted identifiers
|
||||||
|
✓ fromSql() → it ignores schema metadata tables
|
||||||
|
✓ fromSql() → it does not leak int keys for numeric identifiers
|
||||||
|
✓ fromSql() → it returns nothing for non-DML statements
|
||||||
|
✓ fromMigrationSource() → it extracts tables from Schema builder calls
|
||||||
|
✓ fromMigrationSource() → it extracts tables from raw DDL statements
|
||||||
|
✓ fromMigrationSource() → it records the table, not the schema, in qualified DDL and DML
|
||||||
|
✓ fromMigrationSource() → it does not leak int keys for numeric table names
|
||||||
|
✓ fromMigrationSource() → it extracts tables from DB::table calls
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\TestPaths
|
||||||
|
✓ isTestFile() → it matches a standard test file under a configured directory
|
||||||
|
✓ isTestFile() → it does not match a non-test file that shares the directory
|
||||||
|
✓ isTestFile() → it does not match files outside the configured directories
|
||||||
|
✓ isTestFile() → it matches an explicitly listed file
|
||||||
|
✓ isTestFile() → it honours a bare .php suffix
|
||||||
|
✓ isTestFile() → it does not require a dot before the suffix
|
||||||
|
|
||||||
|
PASS Tests\Unit\Plugins\Tia\ViteDepsHelper
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('plain-object')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('plain-array')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('nested')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('empty-object')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('empty-array')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-own-line')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-trailing')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('leading-line-comments')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-eof-no-newline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-leading')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-inline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-multiline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-eof')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('jsdoc-style-block')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-contains-double-slash')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('line-then-block')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-quotes')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-braces-commas')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-object')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-array')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-nested')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-newline')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-block')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-line')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('crlf-line-comment')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('tabs-whitespace')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('url-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('glob-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('path-alias-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-open-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-close-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-both-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('star-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('single-slashes-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('escaped-quote-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('escaped-backslash-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('backslash-then-slash')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('newline-escape-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('unicode-in-string')
|
||||||
|
✓ it strips JSONC down to something JSON.parse accepts with ('block-secret')
|
||||||
|
✓ it never touches comment-looking sequences inside string values
|
||||||
|
✓ it removes the comment body entirely
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('basic')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('multiple-aliases')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('default-baseUrl')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('baseUrl-subdir')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('jsconfig-fallback')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('first-target-wins')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('multiple-keys-mixed')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('no-paths')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('no-compiler-options')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('key-without-glob')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('target-without-glob')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('target-not-array')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('target-empty-array')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('commented-tsconfig')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('trailing-comma-paths')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('malformed-json')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('missing-config')
|
||||||
|
✓ it builds the expected alias map from a tsconfig with ('tsconfig-precedence')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('root-slash-literal')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('path-resolve')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('file-url')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('tilde-alias')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('multiple-aliases')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('laravel-plugin-default')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin')
|
||||||
|
✓ it builds the expected alias map from a vite config with ('no-config')
|
||||||
|
|
||||||
PASS Tests\Unit\Preset
|
PASS Tests\Unit\Preset
|
||||||
✓ preset invalid name
|
✓ preset invalid name
|
||||||
✓ preset → myFramework
|
✓ preset → myFramework
|
||||||
@@ -1908,6 +2190,8 @@
|
|||||||
✓ parallel
|
✓ parallel
|
||||||
✓ a parallel test can extend another test with same name
|
✓ a parallel test can extend another test with same name
|
||||||
✓ parallel reports invalid datasets as failures
|
✓ parallel reports invalid datasets as failures
|
||||||
|
✓ parallel can have multiple exclude-groups
|
||||||
|
✓ parallel can have multiple groups
|
||||||
|
|
||||||
PASS Tests\Visual\ParallelNestedDatasets
|
PASS Tests\Visual\ParallelNestedDatasets
|
||||||
✓ parallel loads nested datasets from nested directories
|
✓ parallel loads nested datasets from nested directories
|
||||||
@@ -1941,4 +2225,4 @@
|
|||||||
✓ pass with dataset with ('my-datas-set-value')
|
✓ pass with dataset with ('my-datas-set-value')
|
||||||
✓ within describe → pass with dataset with ('my-datas-set-value')
|
✓ within describe → pass with dataset with ('my-datas-set-value')
|
||||||
|
|
||||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1330 passed (3010 assertions)
|
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1571 passed (3446 assertions)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Pest\Expectation;
|
use Pest\Expectation;
|
||||||
use Pest\Plugins\Tia\BaselineSync;
|
use Pest\Plugins\Tia\BaselineSync;
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1,12 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Pest\Plugin;
|
use Pest\Plugin;
|
||||||
|
|
||||||
trait PluginTrait
|
trait PluginTrait
|
||||||
{
|
{
|
||||||
public function assertPluginTraitGotRegistered(): void
|
public function assertPluginTraitGotRegistered(): void
|
||||||
{
|
{
|
||||||
$this->assertTrue(true);
|
expect(true)->toBeTrue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,14 +16,14 @@ trait SecondPluginTrait
|
|||||||
{
|
{
|
||||||
public function assertSecondPluginTraitGotRegistered(): void
|
public function assertSecondPluginTraitGotRegistered(): void
|
||||||
{
|
{
|
||||||
$this->assertTrue(true);
|
expect(true)->toBeTrue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Plugin::uses(PluginTrait::class);
|
Plugin::uses(PluginTrait::class);
|
||||||
Plugin::uses(SecondPluginTrait::class);
|
Plugin::uses(SecondPluginTrait::class);
|
||||||
|
|
||||||
function _assertThat()
|
function _assertThat(): void
|
||||||
{
|
{
|
||||||
expect(true)->toBeTrue();
|
expect(true)->toBeTrue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
dataset('bound.closure', function () {
|
dataset('bound.closure', function () {
|
||||||
yield function () {
|
yield fn (): int => 1;
|
||||||
return 1;
|
yield fn (): int => 2;
|
||||||
};
|
|
||||||
yield function () {
|
|
||||||
return 2;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dataset('bound.array', [
|
dataset('bound.array', [
|
||||||
function () {
|
fn (): int => 1,
|
||||||
return 1;
|
fn (): int => 2,
|
||||||
},
|
|
||||||
function () {
|
|
||||||
return 2;
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
dataset('numbers.closure', function () {
|
dataset('numbers.closure', function () {
|
||||||
yield [1];
|
yield [1];
|
||||||
yield [2];
|
yield [2];
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
test('global functions are loaded', function () {
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
test('global functions are loaded', function (): void {
|
||||||
expect(helper_returns_string())->toBeString();
|
expect(helper_returns_string())->toBeString();
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-25
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function (): void {
|
||||||
$this->count = 0;
|
$this->count = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function (): void {
|
||||||
match ($this->name()) {
|
match ($this->name()) {
|
||||||
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(1),
|
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(1),
|
||||||
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(1),
|
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(1),
|
||||||
@@ -21,37 +21,37 @@ afterEach(function () {
|
|||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can run after test', function () {
|
it('can run after test', function (): void {
|
||||||
expect($this->count)->toBe(0);
|
expect($this->count)->toBe(0);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(2);
|
expect($this->count)->toBe(2);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can run after test twice', function () {
|
it('can run after test twice', function (): void {
|
||||||
expect($this->count)->toBe(0);
|
expect($this->count)->toBe(0);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(2);
|
expect($this->count)->toBe(2);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(3);
|
expect($this->count)->toBe(3);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not run when skipped', function () {
|
it('does not run when skipped', function (): void {
|
||||||
dd('This should not run 1');
|
dd('This should not run 1');
|
||||||
})->skip()->after(function () {
|
})->skip()->after(function (): void {
|
||||||
dd('This should not run 2');
|
dd('This should not run 2');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function (): void {
|
||||||
match ($this->name()) {
|
match ($this->name()) {
|
||||||
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(3),
|
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(3),
|
||||||
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(4),
|
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(4),
|
||||||
@@ -70,7 +70,7 @@ afterEach(function () {
|
|||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function (): void {
|
||||||
match ($this->name()) {
|
match ($this->name()) {
|
||||||
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(4),
|
'__pest_evaluable_it_can_run_after_test' => expect($this->count)->toBe(4),
|
||||||
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(5),
|
'__pest_evaluable_it_can_run_after_test_twice' => expect($this->count)->toBe(5),
|
||||||
@@ -87,47 +87,47 @@ afterEach(function () {
|
|||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('something', function () {
|
describe('something', function (): void {
|
||||||
it('does not run when skipped', function () {
|
it('does not run when skipped', function (): void {
|
||||||
dd('This should not run 3');
|
dd('This should not run 3');
|
||||||
})->skip()->after(function () {
|
})->skip()->after(function (): void {
|
||||||
dd('This should not run 4');
|
dd('This should not run 4');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can run after test', function () {
|
it('can run after test', function (): void {
|
||||||
expect($this->count)->toBe(0);
|
expect($this->count)->toBe(0);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(5);
|
expect($this->count)->toBe(5);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(6);
|
expect($this->count)->toBe(6);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(4);
|
expect($this->count)->toBe(4);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('something 2', function () {
|
describe('something 2', function (): void {
|
||||||
it('can run after test', function () {
|
it('can run after test', function (): void {
|
||||||
expect($this->count)->toBe(0);
|
expect($this->count)->toBe(0);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(6);
|
expect($this->count)->toBe(6);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
});
|
});
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(4);
|
expect($this->count)->toBe(4);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
})->after(function () {
|
})->after(function (): void {
|
||||||
expect($this->count)->toBe(5);
|
expect($this->count)->toBe(5);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
@@ -136,7 +136,7 @@ describe('something 2', function () {
|
|||||||
test('high order test')
|
test('high order test')
|
||||||
->defer(fn () => $this->count++)
|
->defer(fn () => $this->count++)
|
||||||
->expect(fn () => $this->count)->toBe(1)
|
->expect(fn () => $this->count)->toBe(1)
|
||||||
->after(function () {
|
->after(function (): void {
|
||||||
expect($this->count)->toBe(4);
|
expect($this->count)->toBe(4);
|
||||||
|
|
||||||
$this->count++;
|
$this->count++;
|
||||||
@@ -146,7 +146,7 @@ test('high order test with skip')
|
|||||||
->skip()
|
->skip()
|
||||||
->defer(fn () => $this->count++)
|
->defer(fn () => $this->count++)
|
||||||
->expect(fn () => $this->count)->toBe(1)
|
->expect(fn () => $this->count)->toBe(1)
|
||||||
->after(function () {
|
->after(function (): void {
|
||||||
dd('This should not run 5');
|
dd('This should not run 5');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
$file = __DIR__.DIRECTORY_SEPARATOR.'after-all-test';
|
$file = __DIR__.DIRECTORY_SEPARATOR.'after-all-test';
|
||||||
|
|
||||||
beforeAll(function () use ($file) {
|
beforeAll(function () use ($file): void {
|
||||||
@unlink($file);
|
@unlink($file);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(function () use ($file) {
|
afterAll(function () use ($file): void {
|
||||||
@unlink($file);
|
@unlink($file);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deletes file after all', function () use ($file) {
|
test('deletes file after all', function () use ($file): void {
|
||||||
file_put_contents($file, 'foo');
|
file_put_contents($file, 'foo');
|
||||||
$this->assertFileExists($file);
|
expect($file)->toBeFile();
|
||||||
register_shutdown_function(function () {
|
register_shutdown_function(function (): void {
|
||||||
// $this->assertFileDoesNotExist($file);
|
// $this->assertFileDoesNotExist($file);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user