Compare commits

..

4 Commits

Author SHA1 Message Date
Nuno Maduro f8c88bd14d chore: requires latest versions of collision and termwind 2024-10-15 16:30:56 +01:00
Nuno Maduro d454a36a48 removes non reported error 2024-10-15 15:34:49 +01:00
Nuno Maduro 61b6b8c7d9 release: v2.36.0 2024-10-15 15:31:46 +01:00
Nuno Maduro e8aaa586cb feat: php 8.4 support 2024-10-15 15:31:29 +01:00
672 changed files with 5336 additions and 30565 deletions
-3
View File
@@ -3,15 +3,12 @@
/tests export-ignore /tests export-ignore
/scripts export-ignore /scripts export-ignore
/.github export-ignore /.github export-ignore
/.hod export-ignore
.editorconfig export-ignore .editorconfig export-ignore
.gitattributes export-ignore .gitattributes export-ignore
.gitignore export-ignore .gitignore export-ignore
/phpstan.neon export-ignore /phpstan.neon export-ignore
/phpunit.xml export-ignore /phpunit.xml export-ignore
/CHANGELOG.md export-ignore /CHANGELOG.md export-ignore
/AGENTS.md export-ignore
/CLAUDE.md export-ignore
/CONTRIBUTING.md export-ignore /CONTRIBUTING.md export-ignore
/docker-compose.yml export-ignore /docker-compose.yml export-ignore
/Makefile export-ignore /Makefile export-ignore
-13
View File
@@ -1,13 +0,0 @@
# Security Policy
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
## Reporting a Vulnerability
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
All security vulnerabilities will be promptly addressed.
-12
View File
@@ -1,12 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
groups:
github-actions:
patterns:
- "*"
+9 -35
View File
@@ -2,17 +2,9 @@ name: Static Analysis
on: on:
push: push:
branches: [5.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 0 * * *'
concurrency:
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
static: static:
@@ -20,45 +12,24 @@ jobs:
name: Static Tests name: Static Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
dependency-version: [prefer-stable] dependency-version: [prefer-lowest, prefer-stable]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 uses: shivammathur/setup-php@v2
with: with:
php-version: 8.4 php-version: 8.1
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets
- name: Get Composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
static-php-8.4-${{ matrix.dependency-version }}-composer-
static-php-8.4-composer-
- name: Install Dependencies - name: Install Dependencies
env: run: composer update --prefer-stable --no-interaction --no-progress --ansi
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check
run: composer test:profanity
- name: Type Check - name: Type Check
run: composer test:type:check run: composer test:type:check
@@ -66,5 +37,8 @@ jobs:
- name: Type Coverage - name: Type Coverage
run: composer test:type:coverage run: composer test:type:coverage
- name: Refacto
run: composer test:refacto
- name: Style - name: Style
run: composer test:lint run: composer test:lint
+10 -36
View File
@@ -2,59 +2,36 @@ name: Tests
on: on:
push: push:
branches: [5.x]
pull_request: pull_request:
schedule:
- cron: '0 9 * * *'
concurrency:
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
tests: tests:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest' if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
os: [ubuntu-latest, macos-latest] # windows-latest os: [ubuntu-latest, macos-latest, windows-latest]
symfony: ['8.0'] symfony: ['6.4', '7.0']
php: ['8.4', '8.5'] php: ['8.1', '8.2', '8.3', '8.4']
dependency_version: [prefer-stable] dependency_version: [prefer-lowest, prefer-stable]
exclude:
- php: '8.1'
symfony: '7.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v4
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 uses: shivammathur/setup-php@v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets
- name: Get Composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
${{ matrix.os }}-php-${{ matrix.php }}-composer-
- name: Setup Problem Matches - name: Setup Problem Matches
run: | run: |
@@ -62,10 +39,7 @@ jobs:
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install PHP dependencies - name: Install PHP dependencies
shell: bash run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:~${{ matrix.symfony }}"
env:
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests - name: Unit Tests
run: composer test:unit run: composer test:unit
+1 -6
View File
@@ -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
@@ -12,8 +12,3 @@ coverage.xml
*.swp *.swp
*.swo *.swo
.vscode/ .vscode/
.STREAM.md
/.claude/skills/
/.agents/skills/
-7
View File
@@ -1,7 +0,0 @@
# The intention of this project
Write the intention of the project here. Say what the project does and who uses it.
Give the command that installs the dependencies, the command that runs the tests and the command that starts the program. Name each directory and give its function.
Write a decision that one directory needs in the `AGENTS.md` file of that directory.
-14
View File
@@ -1,14 +0,0 @@
1a51f3f6c4eef45e6995bb5ac9a82cf3f23ccad0c68cc4f957796bb9fc198f58 .agents/skills/deps-upgrade/SKILL.md
984c7232ae1681ebba980ede2a0543db9a850dd2406990cdfa011384f929e981 .agents/skills/deps-upgrade/agents/openai.yaml
e68fb750f54ca3b17d6797954cb7838f0415c2faa65496ae68cb3918474c68bf .agents/skills/init/SKILL.md
14ea76cbc5a8f35356ccf6aa20beddb249c6bc4aeb9999ec722c3acf92389d57 .agents/skills/init/agents/openai.yaml
3f7f77275ada85b55ed874cea8ada94ad6f838800311f46ecb2a31b68ae95ac7 .agents/skills/snapshots-update/SKILL.md
bb5339d7abcaba163a384a00a9ba8607ddf8b6820a63be3570e9b6fc6a912479 .agents/skills/snapshots-update/agents/openai.yaml
1a51f3f6c4eef45e6995bb5ac9a82cf3f23ccad0c68cc4f957796bb9fc198f58 .claude/skills/deps-upgrade/SKILL.md
984c7232ae1681ebba980ede2a0543db9a850dd2406990cdfa011384f929e981 .claude/skills/deps-upgrade/agents/openai.yaml
e68fb750f54ca3b17d6797954cb7838f0415c2faa65496ae68cb3918474c68bf .claude/skills/init/SKILL.md
14ea76cbc5a8f35356ccf6aa20beddb249c6bc4aeb9999ec722c3acf92389d57 .claude/skills/init/agents/openai.yaml
3f7f77275ada85b55ed874cea8ada94ad6f838800311f46ecb2a31b68ae95ac7 .claude/skills/snapshots-update/SKILL.md
bb5339d7abcaba163a384a00a9ba8607ddf8b6820a63be3570e9b6fc6a912479 .claude/skills/snapshots-update/agents/openai.yaml
d1e4a22c7e3b8b833d873b139d331d85fb9f5247818911b35082ae9a8f6edf9f AGENTS.md
336cc4fbf19beaada7ccf9986414fa91851a8d7a07dfb3ccbe800a69eed0ab49 CLAUDE.md
-74
View File
@@ -1,74 +0,0 @@
---
name: snapshots-update
description: "Write each snapshot of this project again, then run the integration suite until it passes. Use when the user asks to update the snapshots, when a test of `tests/Visual/` fails, or when `composer test:integration` fails after a change of the number of the tests."
disable-model-invocation: true
---
# Snapshots
Write each snapshot of this project again, then run `composer test:integration` until it passes.
`tests/Pest.php` puts each test of `tests/Visual/` in the group `integration`, and each of those tests asserts the output of a full run of the suite. Thus a change of the number of the tests, of the number of the assertions, or of one line of the output of `bin/pest` breaks the integration suite, and this skill repairs it.
Obey the order of the sections. Section 3 writes the number of the tests of a clean run, and section 4 writes that number into `tests/.snapshots/success.txt`. The reverse order writes the failure of the test `parallel` into that snapshot, and the integration suite then fails on the test `visual snapshot of test suite on success`.
## 1. Read the working tree
Run `git status --short`. Name each file that carries a change, then continue with that change in place, because the user starts this skill in the middle of a change.
Write no commit and no tag during this work. Leave each change in the working tree.
## 2. Turn off the coverage driver
Put `XDEBUG_MODE=off` in front of each command of this skill. `.github/workflows/tests.yml` gives `coverage: none` to each job, thus a run of this project on GitHub collects no coverage, and `tests/.snapshots/success.txt` holds the line `it adds coverage if --coverage exist → Coverage is not available`. A run with a coverage driver writes one test more as passed, and the snapshot then fails on GitHub.
Run `php -m | grep -iE "xdebug|pcov"` and read the name of each coverage driver of this computer. `XDEBUG_MODE=off` turns off `xdebug`. Add `-d pcov.enabled=0` to `php` for `pcov`, and run the command of the composer script `update:snapshots` directly for that flag.
## 3. Write the summary of the parallel run
Run this command with a timeout of 900 seconds:
```
XDEBUG_MODE=off COLLISION_PRINTER=DefaultPrinter COLLISION_IGNORE_DURATION=true PAO_DISABLE=1 php bin/pest --parallel --processes=3 --exclude-group=integration
```
Read the line that starts with `Tests:`. Take the text after `Tests:` up to and with `(<number> assertions)`, such as `2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1578 passed (3426 assertions)`. That text is the summary.
Run `grep -n "assertions)';" tests/Visual/Parallel.php`. The command gives one line, and that line holds the value of `$expected` of the test `parallel`. Write the summary in that line when the two texts differ, and change no other line of the file.
## 4. Write each snapshot again
Run `XDEBUG_MODE=off composer update:snapshots` with a timeout of 1800 seconds. The command runs `REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots --exclude-group=tia`, and it writes `tests/.snapshots/success.txt` and each file under `tests/.pest/snapshots/`. The command starts a full suite inside a test of `tests/Visual/`, thus the command needs some minutes.
The command exits with the code 2 after a correct run. Read the output and accept these three results:
- a `FAILED` test of `tests/Visual/`, because the test reads the old snapshot and writes the new snapshot in the same run.
- a `FAILED` test of `tests/Features/Flaky.php`, because `--update-snapshots` writes each snapshot and a flaky test stops the retry when a snapshot changes.
- a `RISKY` test that performs no assertion, because a test that only writes a snapshot asserts nothing.
Run `XDEBUG_MODE=off composer test:unit` for a `FAILED` test outside `tests/Visual/` and outside `tests/Features/Flaky.php`. That command writes no snapshot, thus it separates a regression of the code from an effect of `--update-snapshots`. Stop when the test fails again, give the report of section 6, and write no snapshot again.
Run `grep -c "Coverage is not available" tests/.snapshots/success.txt`. The count is 1. Return to section 2 for the count 0, because a coverage driver of this computer stays on.
## 5. Run the integration suite
Run `XDEBUG_MODE=off composer test:integration` with a timeout of 3600 seconds.
Read each test that failed, then take one action.
| Test that failed | Action |
| --- | --- |
| the test `parallel` of `tests/Visual/Parallel.php` | Return to section 3, because the numbers of the suite moved after the last measure. |
| a different test of `tests/Visual/` | Return to section 3, because `tests/.snapshots/success.txt` holds the numbers of section 3. |
| a test outside `tests/Visual/` | Stop. Give the report of section 6, and write no snapshot again. |
Return to section 3 two times at the most. Give the report of section 6 after the last run, and name each test that failed in that run.
## 6. Report
Give four items:
- each file that this work changed, from `git status --short`.
- the summary that `tests/Visual/Parallel.php` holds now, and the summary that the file held before this work.
- the result of the last run of `composer test:integration`, and the name of each test that failed in that run.
- each section that you repeated, and the reason.
@@ -1,5 +0,0 @@
interface:
display_name: "Snapshots Update"
short_description: "Write each snapshot of this project again"
policy:
allow_implicit_invocation: false
-75
View File
@@ -1,75 +0,0 @@
# The project
Read `.hod/PROJECT.md` first. It holds the intention of this project.
`hod` writes this file. Write no sentence in it, because `hod update` writes it again. Write the intention of the project in `.hod/PROJECT.md`. Write a rule in a file in `.hod/rules/`. Write a skill in a directory in `.hod/skills/`.
---
## 1. The reader of this file
This file is for a coding agent. No user reads it. Write an instruction that an agent obeys during its work, then stop. Do not write an introduction, a conclusion, an argument for a decision that the project made, or a sentence that no agent can obey. Give a reason only when the reason changes the next decision of the agent.
Start each rule with a verb in the imperative. Put the condition before the instruction. Give the exact path, the exact command and the exact name that the agent must use. If a rule needs a test, give the command that does the test.
The public files are different. `README.md`, the website, the release notes and the `description` in each manifest are for a user. Section 3 controls them. Section 5 controls each message that you give the user.
---
## 2. How to write for an agent
Write in [ASD-STE100](https://www.asd-ste100.org) Simplified Technical English: one instruction in one sentence, the imperative, the active voice, one meaning for one word, no contraction, and no synonym for variety. The standard holds the full rules. Do not copy them here. Four items keep their exact form: a quotation from a standard, an identifier in the code, a path or a command, and a name from a different supplier.
Write GitHub flavored Markdown. Put one `#` heading in a file. Write one paragraph on one line, thus a change stays small in `git diff`. Number the sections of a long file, thus a different file can point to "section 3". Put a path, a command and a name from the code in `code font`.
Write the intention of the project in `.hod/PROJECT.md`. Write a decision about one directory in the `AGENTS.md` file of that directory. If `.hod/PROJECT.md` and the `AGENTS.md` file of a directory disagree, obey `.hod/PROJECT.md` and correct the other file.
Write no new rule. This is the correct result of each task, and a task that adds a command, a module or a workflow does not change it. A rule needs a fault that happened: name the decision that you made wrong, or name the decision that these files refused to give you. A wrong change that you imagine is not a fault, thus write no rule to protect the code that you write today.
A fact that you learned during one task is not a rule. Use it in that task, then write no sentence for it.
`.hod/PROJECT.md` holds two items: the intention of the project, and a reason that the code cannot hold. Write no sentence below that level.
Write no sentence for a fact that an agent finds when it reads the code. The cost of that read does not change the answer: a fact that four files hold is in the code. Put the fact in the code first, because a test stops the agent that breaks it and a sentence here does not.
Ask the user before you write a rule. Give the fault, and write the rule after the user agrees. Give the file the path `.hod/rules/<name>.md`. Give `<name>` two or three words with a hyphen between them, and name the subject of the rule. Give the file `name` and `description` in its front matter, and write one rule in one file. Run `hod update --project` after the write, thus this file names the rule in section 6. Keep the number of the rules: delete a rule file in the same change, or ask the user to accept one more rule. A correction from the user is a rule and needs no question: write it in a file before you continue the work.
Give no new part of the program its own rule, because the code of that part holds its design. Delete text that follows the order of a file of source code: that text describes the file, and the file describes itself. Replace an old rule. Do not write a second rule near it. Delete a rule that the project does not obey. Do not keep a record of what the project stopped doing, in a file or in a directory. Git holds the history.
---
## 3. How to write the public text
These rules control `README.md`, the website, the release notes, the announcement and the `description` in each manifest. Sections 1 and 2 do not control these files.
The voice is calm and certain. Say what the project does, then stop. Do not say that the project is fast, simple, powerful or intelligent. Show the command and let the reader form that opinion. Cut each superlative. Cut each sentence that a competitor can also write about itself.
Write for one person and call that person "you". Put the result first and the mechanism second. Keep the sentences short, and let one sentence stand alone when it carries the idea. A demonstration is the strongest argument: one command in a terminal is worth one paragraph of adjectives.
The care is in the small parts. The alignment of the output, the words in an error message, the space around the text on the page: the reader feels this work before the reader reads one sentence. The project is for people who enjoy their work, thus the text is warm. Put one dry joke on one page, at the most. Do not explain it.
Obey three limits. Do not promise a feature that does not exist today. Do not give a number without its source. Do not name a different product to make a comparison.
---
## 4. How to write the code
These rules control each file of source code and each manifest in this repository. Sections 1, 2 and 3 do not control these files.
Write no comment. Delete each comment that you find in a file that you change. Turn off the lint that asks for a documentation comment, because that lint asks you to say the name of an item again.
Put the intention in the code. Give each item a name that says what the item does. Give each value a type that makes a wrong value impossible. Split a long function into two functions with two names. A name and a type stay correct, and a comment does not.
Write a reason that the code cannot hold in a file in `.hod/rules/`, with the path of the code. Do not write it above the code.
When a library reads the text of a comment as data, such as the description of a command on a help screen, write that text in an attribute or a field of that library instead.
A tool that writes a comment into a file that it owns keeps that comment. Do not delete it: the tool fails until it writes the comment again.
---
## 5. How to write a message to the user
Write each message that you give the user in the English of section 2: one instruction in one sentence, the imperative, the active voice, one meaning for one word, and no contraction. A question, a report, a plan and an answer obey this rule.
Give the result first. Give the exact path, the exact command and the exact name. Write no sentence that says the work again, and no adjective that gives the reader no new fact.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+2 -2
View File
@@ -42,7 +42,7 @@ composer test
Check types: Check types:
```bash ```bash
composer test:type:check composer test:types
``` ```
Unit tests: Unit tests:
@@ -69,7 +69,7 @@ If you want to check things work against a specific version of PHP, you may incl
the `PHP` build argument when building the image: the `PHP` build argument when building the image:
```bash ```bash
make build ARGS="--build-arg PHP=8.3" make build ARGS="--build-arg PHP=8.2"
``` ```
The default PHP version will always be the lowest version of PHP supported by Pest. The default PHP version will always be the lowest version of PHP supported by Pest.
+14
View File
@@ -0,0 +1,14 @@
# Well documented Makefiles
DEFAULT_GOAL := help
help:
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
build: ## Build all docker images. Specify the command e.g. via make build ARGS="--build-arg PHP=8.2"
docker compose build $(ARGS)
##@ [Application]
install: ## Install the composer dependencies
docker compose run --rm composer install
test: ## Run the tests
docker compose run --rm composer test
+22 -29
View File
@@ -1,47 +1,40 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST"> <img src="https://raw.githubusercontent.com/pestphp/art/master/v2/banner.png" width="600" alt="PEST">
<p align="center"> <p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a> <a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=2.x&label=Tests%202.x"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a> <a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a> <a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a> <a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
</p> </p>
</p> </p>
------ ------
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
**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 us on Twitter at **[@pestphp »](https://twitter.com/pestphp)**
- YouTube: **[youtube.com/@nunomaduro](https://youtube.com/@nunomaduro)** — Videos every week - Join us at **[discord.gg/kaHY6p54JH »](https://discord.gg/kaHY6p54JH)** or **[t.me/+kYH5G4d5MV83ODk0 »](https://t.me/+kYH5G4d5MV83ODk0)**
- Twitch: **[twitch.tv/nunomaduro](https://twitch.tv/nunomaduro)** — Live coding on Mondays, Wednesdays, and Fridays at 9PM UTC
- Twitter / X: **[x.com/enunomaduro](https://x.com/enunomaduro)**
- LinkedIn: **[linkedin.com/in/nunomaduro](https://www.linkedin.com/in/nunomaduro)**
- Instagram: **[instagram.com/enunomaduro](https://www.instagram.com/enunomaduro)**
- Tiktok: **[tiktok.com/@enunomaduro](https://www.tiktok.com/@enunomaduro)**
## Sponsors ## Sponsors
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)**.
- **[PhpStorm](https://jb.gg/nuno)** ### Platinum Sponsors
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)** - **[LaraJobs](https://larajobs.com)**
- **[Bento](https://bentonow.com/?ref=nunomaduro)** - **[Brokerchooser](https://brokerchooser.com)**
- **[CMS Max](https://cmsmax.com/?ref=nunomaduro)** - **[Forge](https://forge.laravel.com)**
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)** - **[Spatie](https://spatie.be)**
- **[Typesense](https://typesense.org/?ref=nunomaduro)** - **[Worksome](https://www.worksome.com/)**
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
- **[NativePHP](https://nativephp.com/?ref=nunomaduro)** ### Premium Sponsors
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
- [Zapiet](https://zapiet.com/?ref=nunomaduro) - [Akaunting](https://akaunting.com/?ref=pestphp)
- [LoadForge](https://loadforge.com/?ref=nunomaduro) - [Codecourse](https://codecourse.com/?ref=pestphp)
- [Route4Me](https://route4me.com/?ref=nunomaduro) - [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp)
- [Nerdify](https://getnerdify.com/?ref=nunomaduro) - [Laracasts](https://laracasts.com/?ref=pestphp)
- [Localazy](https://localazy.com/?ref=pestphp)
- [Route4Me](https://www.route4me.com/?ref=pestphp)
- [Zapiet](https://www.zapiet.com/?ref=pestphp)
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
View File
@@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done: When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v4 you should use the `4.x` branch instead.** > **For Pest v1 you should use the `1.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x` - Clear your local repository with: `git add . && git reset --hard && git checkout 2.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x) - On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...2.x](https://github.com/pestphp/pest/compare/{latest_version}...2.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"`
+4 -120
View File
@@ -1,18 +1,9 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php declare(strict_types=1);
declare(strict_types=1);
use Pest\Contracts\Restarter;
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
use Pest\TestCaseMethodFilters\NotesTestCaseFilter;
use Pest\TestCaseMethodFilters\PrTestCaseFilter;
use Pest\TestCaseMethodFilters\FlakyTestCaseFilter;
use Pest\TestCaseMethodFilters\TodoTestCaseFilter; use Pest\TestCaseMethodFilters\TodoTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
@@ -26,11 +17,8 @@ use Symfony\Component\Console\Output\ConsoleOutput;
$dirty = false; $dirty = false;
$todo = false; $todo = false;
$flaky = false;
$notes = false;
foreach ($arguments as $key => $value) { foreach ($arguments as $key => $value) {
if ($value === '--compact') { if ($value === '--compact') {
$_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true';
unset($arguments[$key]); unset($arguments[$key]);
@@ -41,14 +29,8 @@ use Symfony\Component\Console\Output\ConsoleOutput;
unset($arguments[$key]); unset($arguments[$key]);
} }
if (str_contains($value, '--test-directory=')) { if (str_contains($value, '--test-directory')) {
unset($arguments[$key]); unset($arguments[$key]);
} elseif ($value === '--test-directory') {
unset($arguments[$key]);
if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
} }
if ($value === '--dirty') { if ($value === '--dirty') {
@@ -61,66 +43,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
unset($arguments[$key]); unset($arguments[$key]);
} }
if ($value === '--flaky') {
$flaky = true;
unset($arguments[$key]);
}
if ($value === '--notes') {
$notes = true;
unset($arguments[$key]);
}
if (str_contains($value, '--assignee=')) {
unset($arguments[$key]);
} elseif ($value === '--assignee') {
unset($arguments[$key]);
if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
}
if (str_contains($value, '--issue=')) {
unset($arguments[$key]);
} elseif ($value === '--issue') {
unset($arguments[$key]);
if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
}
if (str_contains($value, '--ticket=')) {
unset($arguments[$key]);
} elseif ($value === '--ticket') {
unset($arguments[$key]);
if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
}
if (str_contains($value, '--pr=')) {
unset($arguments[$key]);
} elseif ($value === '--pr') {
unset($arguments[$key]);
if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
}
if (str_contains($value, '--pull-request=')) {
unset($arguments[$key]);
} elseif ($value === '--pull-request') {
unset($arguments[$key]);
if (isset($arguments[$key + 1])) {
unset($arguments[$key + 1]);
}
}
if (str_contains($value, '--teamcity')) { if (str_contains($value, '--teamcity')) {
unset($arguments[$key]); unset($arguments[$key]);
$arguments[] = '--no-output'; $arguments[] = '--no-output';
@@ -144,8 +66,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $rootPath = dirname($autoloadPath, 2);
$input = new ArgvInput();
$input = new ArgvInput;
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
$rootPath, $rootPath,
@@ -157,35 +78,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
} }
if ($todo) { if ($todo) {
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter); $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter());
}
if ($flaky) {
$testSuite->tests->addTestCaseMethodFilter(new FlakyTestCaseFilter);
}
if ($notes) {
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
}
if ($assignee = $input->getParameterOption('--assignee')) {
$testSuite->tests->addTestCaseMethodFilter(new AssigneeTestCaseFilter((string) $assignee));
}
if ($issue = $input->getParameterOption('--issue')) {
$testSuite->tests->addTestCaseMethodFilter(new IssueTestCaseFilter((int) $issue));
}
if ($issue = $input->getParameterOption('--ticket')) {
$testSuite->tests->addTestCaseMethodFilter(new IssueTestCaseFilter((int) $issue));
}
if ($pr = $input->getParameterOption('--pr')) {
$testSuite->tests->addTestCaseMethodFilter(new PrTestCaseFilter((int) $pr));
}
if ($pr = $input->getParameterOption('--pull-request')) {
$testSuite->tests->addTestCaseMethodFilter(new PrTestCaseFilter((int) $pr));
} }
$isDecorated = $input->getParameterOption('--colors', 'always') !== 'never'; $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never';
@@ -195,15 +88,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try { try {
$kernel = Kernel::boot($testSuite, $input, $output); $kernel = Kernel::boot($testSuite, $input, $output);
$container = Container::getInstance();
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
assert($restarter instanceof Restarter);
$restarter->maybeRestart($rootPath, $originalArguments);
}
$result = $kernel->handle($originalArguments, $arguments); $result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate(); $kernel->terminate();
-393
View File
@@ -1,393 +0,0 @@
#!/usr/bin/env node
import { readdir, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve, relative, extname, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PAGE_EXTENSIONS = new Set([
'.vue', '.svelte',
'.tsx', '.jsx',
'.ts', '.js',
'.mts', '.cts', '.mjs', '.cjs',
])
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
]
async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
let path = null
try { path = projectRequire.resolve('rolldown') } catch {}
if (path === null) {
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
// dependency of the vite package rather than a top-level install.
try {
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
path = viteRequire.resolve('rolldown')
} catch {}
}
if (path === null) return null
return await import(pathToFileURL(path).href)
}
export function stripJsonComments(raw) {
let out = ''
let inString = false
let quote = ''
let inLine = false
let inBlock = false
for (let i = 0; i < raw.length; i++) {
const c = raw[i]
const n = raw[i + 1]
if (inLine) {
if (c === '\n') { inLine = false; out += c }
continue
}
if (inBlock) {
if (c === '*' && n === '/') { inBlock = false; i++ }
continue
}
if (inString) {
out += c
if (c === '\\') { out += n ?? ''; i++; continue }
if (c === quote) inString = false
continue
}
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
if (c === '/' && n === '/') { inLine = true; i++; continue }
if (c === '/' && n === '*') { inBlock = true; i++; continue }
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
out += c
}
return out
}
async function readJsonWithComments(path) {
const raw = await readFile(path, 'utf8')
return JSON.parse(stripJsonComments(raw))
}
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(projectRoot, name)
if (!existsSync(p)) continue
let cfg
try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
const paths = cfg?.compilerOptions?.paths ?? {}
for (const [key, targets] of Object.entries(paths)) {
if (!key.endsWith('/*')) continue
const t0 = Array.isArray(targets) ? targets[0] : null
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
const aliasKey = key.slice(0, -2)
if (alias[aliasKey] !== undefined) continue
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
}
}
return alias
}
const VITE_CONFIG_FILES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
'vite.config.cts',
]
function resolveAliasTarget(projectRoot, target) {
if (target.startsWith('/')) {
// Vite resolves a leading slash against the project root, unless the
// config author used a genuinely absolute path.
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
}
return resolve(projectRoot, target)
}
async function usesLaravelVitePlugin(projectRoot) {
const p = join(projectRoot, 'package.json')
if (!existsSync(p)) return false
try {
const pkg = JSON.parse(await readFile(p, 'utf8'))
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
} catch {
return false
}
}
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
const alias = {}
let source = null
for (const name of VITE_CONFIG_FILES) {
const p = join(projectRoot, name)
if (!existsSync(p)) continue
try { source = await readFile(p, 'utf8') } catch { continue }
break
}
if (source !== null) {
// The config is executable code we cannot import safely, so extract alias
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
// the value expression — covers `'@': '/resources/js'` as well as
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
// top-level comma; call arguments are kept via the paren group.
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
const key = m[1]
if (alias[key] !== undefined) continue
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
const target = paths.length > 0 ? paths[paths.length - 1] : null
if (!target || target.includes('*')) continue
alias[key] = resolveAliasTarget(projectRoot, target)
}
}
// laravel-vite-plugin registers '@' → resources/js by default.
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
alias['@'] = resolve(projectRoot, 'resources/js')
}
return alias
}
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
const out = []
const walk = async (dir) => {
let entries
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) { await walk(full); continue }
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
}
}
await walk(pagesDir)
return out
}
// `existsSync()` ignores casing on APFS and NTFS, and on a macOS bind mount seen from
// inside a Linux container, so the `resources/js/Pages` candidate resolves in a project
// whose directory is really `resources/js/pages`. Every page path derives from the
// directory accepted below, so a wrong-cased one detaches the pages tree from the paths
// the PHP side reports. `readdir()` is case-exact everywhere.
export async function matchesDiskCasing(projectRoot, rel) {
let current = projectRoot
for (const segment of rel.split('/')) {
let entries
try { entries = await readdir(current) } catch { return false }
if (!entries.includes(segment)) return false
current = join(current, segment)
}
return true
}
async function discoverPagesDir() {
const override = process.env.TIA_VITE_PAGES_DIR
if (override && override.length > 0) {
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
}
for (const rel of PAGE_DIR_CANDIDATES) {
const abs = resolve(PROJECT_ROOT, rel)
if (!existsSync(abs)) continue
if (!(await matchesDiskCasing(PROJECT_ROOT, rel))) continue
const files = await listPageFiles(abs)
if (files.length > 0) return abs
}
return null
}
function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel)
return rel.slice(0, rel.length - ext.length)
}
function isLocalSpecifier(source, aliasKeys) {
if (source.startsWith('.') || source.startsWith('/')) return true
for (const key of aliasKeys) {
if (source === key || source.startsWith(key + '/')) return true
}
return false
}
async function main() {
const pagesDir = await discoverPagesDir()
if (pagesDir === null) {
process.stdout.write('{}')
return
}
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {
process.stdout.write('{}')
return
}
const loaded = await loadRolldown()
if (loaded === null) {
process.stdout.write('{}')
return
}
const { rolldown } = loaded
// The vite config is what the dev server actually resolves with — let it win.
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
const aliasKeys = Object.keys(alias)
const graph = new Map()
const collector = {
name: 'pest-tia-collector',
moduleParsed(info) {
const id = info.id
if (!id || id.startsWith('\0')) return
const deps = new Set()
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
graph.set(id, deps)
},
}
const externalBare = {
name: 'pest-tia-external-bare',
resolveId(source) {
if (!source) return null
if (isLocalSpecifier(source, aliasKeys)) return null
return { id: source, external: true }
},
}
const assetStub = {
name: 'pest-tia-asset-stub',
load(id) {
if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
}
return null
},
}
const input = Object.create(null)
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
const bundle = await rolldown({
input,
cwd: PROJECT_ROOT,
resolve: {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
},
transform: { jsx: 'preserve' },
treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
try {
await bundle.generate({ format: 'esm' })
} finally {
await bundle.close()
}
const reverse = new Map()
const transitiveCache = new Map()
const computeTransitive = (id, stack) => {
const cached = transitiveCache.get(id)
if (cached) return cached
if (stack.has(id)) return null
stack.add(id)
const acc = new Set()
// A set computed while skipping an in-stack (cyclic) dependency is missing
// that dependency's subtree and must not be memoized — the ancestor call
// completes it for the current traversal, but a cached copy would leak the
// incomplete set into other pages' traversals.
let complete = true
const deps = graph.get(id)
if (deps) {
for (const dep of deps) {
if (!dep || dep.startsWith('\0')) continue
if (dep.startsWith(PROJECT_ROOT)) {
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
acc.add(rel)
}
if (stack.has(dep)) {
complete = false
continue
}
const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r)
if (!transitiveCache.has(dep)) complete = false
}
}
stack.delete(id)
if (complete) transitiveCache.set(id, acc)
return acc
}
for (const page of pages) {
const pageComponent = componentNameFor(page, pagesDir)
const reachable = computeTransitive(page, new Set())
if (!reachable) continue
for (const rel of reachable) {
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
}
}
const payload = Object.create(null)
const keys = [...reverse.keys()].sort()
for (const key of keys) {
payload[key] = [...reverse.get(key)].sort()
}
process.stdout.write(JSON.stringify(payload))
}
const invokedDirectly = process.argv[1] !== undefined
&& import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
try {
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}
}
+5 -19
View File
@@ -6,7 +6,6 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel; use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
@@ -33,13 +32,10 @@ $bootPest = (static function (): void {
'status-file:', 'status-file:',
'progress-file:', 'progress-file:',
'unexpected-output-file:', 'unexpected-output-file:',
'test-result-file:', 'testresult-file:',
'result-cache-file:',
'teamcity-file:', 'teamcity-file:',
'testdox-file:', 'testdox-file:',
'testdox-color', 'testdox-color',
'testdox-columns:',
'testdox-summary',
'phpunit-argv:', 'phpunit-argv:',
]); ]);
@@ -59,23 +55,13 @@ $bootPest = (static function (): void {
} }
} }
$container = Container::getInstance();
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
}
assert(isset($getopt['status-file']) && is_string($getopt['status-file'])); assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb'); $statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile)); assert(is_resource($statusFile));
assert(isset($getopt['progress-file']) && is_string($getopt['progress-file'])); assert(isset($getopt['progress-file']) && is_string($getopt['progress-file']));
assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file'])); assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file']));
assert(isset($getopt['test-result-file']) && is_string($getopt['test-result-file'])); assert(isset($getopt['testresult-file']) && is_string($getopt['testresult-file']));
assert(! isset($getopt['result-cache-file']) || is_string($getopt['result-cache-file']));
assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file'])); assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file']));
assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file'])); assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file']));
@@ -91,12 +77,11 @@ $bootPest = (static function (): void {
$phpunitArgv, $phpunitArgv,
$getopt['progress-file'], $getopt['progress-file'],
$getopt['unexpected-output-file'], $getopt['unexpected-output-file'],
$getopt['test-result-file'], $getopt['testresult-file'],
$getopt['result-cache-file'] ?? null,
$getopt['teamcity-file'] ?? null, $getopt['teamcity-file'] ?? null,
$getopt['testdox-file'] ?? null, $getopt['testdox-file'] ?? null,
isset($getopt['testdox-color']), isset($getopt['testdox-color']),
(int) ($getopt['testdox-columns'] ?? null), $getopt['testdox-columns'] ?? null,
); );
while (true) { while (true) {
@@ -111,6 +96,7 @@ $bootPest = (static function (): void {
exit; exit;
} }
// It must be a 1 byte string to ensure filesize() is equal to the number of tests executed
$exitCode = $application->runTest(realpath(trim($testPath))); $exitCode = $application->runTest(realpath(trim($testPath)));
fwrite($statusFile, (string) $exitCode); fwrite($statusFile, (string) $exitCode);
+23 -43
View File
@@ -17,21 +17,18 @@
} }
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.1.0",
"brianium/paratest": "^7.24.1", "brianium/paratest": "^7.3.1",
"nunomaduro/collision": "^8.9.5", "nunomaduro/collision": "^7.11.0|^8.4.0",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^1.16.0|^2.1.0",
"pestphp/pest-plugin": "^5.0.0", "pestphp/pest-plugin": "^2.1.1",
"pestphp/pest-plugin-arch": "^5.0.0", "pestphp/pest-plugin-arch": "^2.7.0",
"pestphp/pest-plugin-mutate": "^5.0.2", "phpunit/phpunit": "^10.5.36"
"pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^13.3.1",
"symfony/process": "^8.1.5"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.16.0",
"phpunit/phpunit": ">13.3.1", "phpunit/phpunit": ">10.5.36",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<5.1.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
"autoload": { "autoload": {
@@ -48,30 +45,22 @@
"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": [
"tests/Fixtures/Arch/ToBeCasedCorrectly/IncorrectCasing/incorrectCasing.php"
],
"files": [ "files": [
"tests/Autoload.php" "tests/Autoload.php"
] ]
}, },
"require-dev": { "require-dev": {
"pestphp/pest-dev-tools": "^5.0.0", "pestphp/pest-dev-tools": "^2.17.0",
"pestphp/pest-plugin-browser": "^5.0.1", "pestphp/pest-plugin-type-coverage": "^2.8.7",
"pestphp/pest-plugin-phpstan": "^5.2.0", "symfony/process": "^6.4.0|^7.1.5"
"pestphp/pest-plugin-rector": "^5.0.4",
"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,
"config": { "config": {
"sort-packages": true, "sort-packages": true,
"preferred-install": "dist", "preferred-install": "dist",
"process-timeout": 0,
"allow-plugins": { "allow-plugins": {
"pestphp/pest-plugin": true "pestphp/pest-plugin": true
} }
@@ -80,24 +69,19 @@
"bin/pest" "bin/pest"
], ],
"scripts": { "scripts": {
"lint": [ "refacto": "rector",
"rector", "lint": "pint",
"pint --parallel" "test:refacto": "rector --dry-run",
], "test:lint": "pint --test",
"test:lint": [
"rector --dry-run",
"pint --parallel --test"
],
"test:profanity": "php bin/pest --profanity --compact",
"test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug", "test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug",
"test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100", "test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100",
"test:unit": "php bin/pest --exclude-group=integration --compact", "test:unit": "php bin/pest --colors=always --exclude-group=integration --compact",
"test:inline": "php bin/pest --configuration=phpunit.inline.xml", "test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3", "test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v", "test:integration": "php bin/pest --colors=always --group=integration",
"test:tia": "php bin/pest --group=tia -v", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots --exclude-group=tia",
"test": [ "test": [
"@test:refacto",
"@test:lint", "@test:lint",
"@test:type:check", "@test:type:check",
"@test:type:coverage", "@test:type:coverage",
@@ -109,8 +93,6 @@
"extra": { "extra": {
"pest": { "pest": {
"plugins": [ "plugins": [
"Pest\\Mutate\\Plugins\\Mutate",
"Pest\\Plugins\\Configuration",
"Pest\\Plugins\\Bail", "Pest\\Plugins\\Bail",
"Pest\\Plugins\\Cache", "Pest\\Plugins\\Cache",
"Pest\\Plugins\\Coverage", "Pest\\Plugins\\Coverage",
@@ -126,8 +108,6 @@
"Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Snapshot",
"Pest\\Plugins\\Verbose", "Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version", "Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard",
"Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel" "Pest\\Plugins\\Parallel"
] ]
}, },
+14
View File
@@ -0,0 +1,14 @@
version: "3.8"
services:
php:
build:
context: ./docker
volumes:
- .:/var/www/html
composer:
build:
context: ./docker
volumes:
- .:/var/www/html
entrypoint: ["composer"]
+3 -5
View File
@@ -20,11 +20,9 @@ use PHPUnit\Util\Filter;
use PHPUnit\Util\ThrowableToStringMapper; use PHPUnit\Util\ThrowableToStringMapper;
/** /**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @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
*/ */
final readonly class ThrowableBuilder final class ThrowableBuilder
{ {
/** /**
* @throws Exception * @throws Exception
@@ -38,7 +36,7 @@ final readonly class ThrowableBuilder
$previous = self::from($previous); $previous = self::from($previous);
} }
$trace = Filter::stackTraceFromThrowableAsString($t); $trace = Filter::getFilteredStacktrace($t);
if ($t instanceof RenderableOnCollisionEditor && $frame = $t->toCollisionEditor()) { if ($t instanceof RenderableOnCollisionEditor && $frame = $t->toCollisionEditor()) {
$file = $frame->getFile(); $file = $frame->getFile();
@@ -52,7 +50,7 @@ final readonly class ThrowableBuilder
$t->getMessage(), $t->getMessage(),
ThrowableToStringMapper::map($t), ThrowableToStringMapper::map($t),
$trace, $trace,
$previous, $previous
); );
} }
} }
+34 -42
View File
@@ -1,7 +1,6 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
/* /*
* This file is part of PHPUnit. * This file is part of PHPUnit.
* *
@@ -15,9 +14,6 @@ namespace PHPUnit\Logging\JUnit;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use Pest\Logging\Converter;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\EventFacadeIsSealedException; use PHPUnit\Event\EventFacadeIsSealedException;
@@ -31,7 +27,6 @@ use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\MarkedIncomplete; use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\PreparationStarted; use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\Started; use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\UnknownSubscriberTypeException; use PHPUnit\Event\UnknownSubscriberTypeException;
@@ -46,15 +41,13 @@ use function str_replace;
use function trim; use function trim;
/** /**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @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
*/ */
final class JunitXmlLogger final class JunitXmlLogger
{ {
private readonly Printer $printer; private readonly Printer $printer;
private readonly Converter $converter; private readonly \Pest\Logging\Converter $converter; // pest-added
private DOMDocument $document; private DOMDocument $document;
@@ -66,32 +59,32 @@ final class JunitXmlLogger
private array $testSuites = []; private array $testSuites = [];
/** /**
* @var array<int,int> * @psalm-var array<int,int>
*/ */
private array $testSuiteTests = [0]; private array $testSuiteTests = [0];
/** /**
* @var array<int,int> * @psalm-var array<int,int>
*/ */
private array $testSuiteAssertions = [0]; private array $testSuiteAssertions = [0];
/** /**
* @var array<int,int> * @psalm-var array<int,int>
*/ */
private array $testSuiteErrors = [0]; private array $testSuiteErrors = [0];
/** /**
* @var array<int,int> * @psalm-var array<int,int>
*/ */
private array $testSuiteFailures = [0]; private array $testSuiteFailures = [0];
/** /**
* @var array<int,int> * @psalm-var array<int,int>
*/ */
private array $testSuiteSkipped = [0]; private array $testSuiteSkipped = [0];
/** /**
* @var array<int,int> * @psalm-var array<int,int>
*/ */
private array $testSuiteTimes = [0]; private array $testSuiteTimes = [0];
@@ -112,14 +105,15 @@ final class JunitXmlLogger
public function __construct(Printer $printer, Facade $facade) public function __construct(Printer $printer, Facade $facade)
{ {
$this->printer = $printer; $this->printer = $printer;
$this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath); $this->converter = new \Pest\Logging\Converter(\Pest\Support\Container::getInstance()->get(\Pest\TestSuite::class)->rootPath); // pest-added
$this->registerSubscribers($facade); $this->registerSubscribers($facade);
$this->createDocument(); $this->createDocument();
} }
public function flush(): void public function flush(): void
{ {
$this->printer->print($this->document->saveXML() ?: ''); $this->printer->print($this->document->saveXML());
$this->printer->flush(); $this->printer->flush();
} }
@@ -127,9 +121,10 @@ final class JunitXmlLogger
public function testSuiteStarted(Started $event): void public function testSuiteStarted(Started $event): void
{ {
$testSuite = $this->document->createElement('testsuite'); $testSuite = $this->document->createElement('testsuite');
$testSuite->setAttribute('name', $this->converter->getTestSuiteName($event->testSuite())); $testSuite->setAttribute('name', $this->converter->getTestSuiteName($event->testSuite())); // pest-changed
if ($event->testSuite()->isForTestClass()) { if ($event->testSuite()->isForTestClass()) {
$testSuite->setAttribute('file', $this->converter->getTestSuiteLocation($event->testSuite()) ?? ''); $testSuite->setAttribute('file', $this->converter->getTestSuiteLocation($event->testSuite()) ?? ''); // pest-changed
} }
if ($this->testSuiteLevel > 0) { if ($this->testSuiteLevel > 0) {
@@ -200,34 +195,28 @@ final class JunitXmlLogger
$this->createTestCase($event); $this->createTestCase($event);
} }
/**
* @throws InvalidArgumentException
*/
public function testPreparationFailed(): void public function testPreparationFailed(): void
{ {
$this->preparationFailed = true; $this->preparationFailed = true;
} }
/**
* @throws InvalidArgumentException
*/
public function testPrepared(): void public function testPrepared(): void
{ {
$this->prepared = true; $this->prepared = true;
} }
public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $event): void
{
assert($this->currentTestCase !== null);
$systemOut = $this->document->createElement(
'system-out',
Xml::prepareString($event->output()),
);
$this->currentTestCase->appendChild($systemOut);
}
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function testFinished(Finished $event): void public function testFinished(Finished $event): void
{ {
if (! $this->prepared || $this->preparationFailed) { if ($this->preparationFailed) {
return; return;
} }
@@ -316,7 +305,6 @@ final class JunitXmlLogger
new TestPreparationStartedSubscriber($this), new TestPreparationStartedSubscriber($this),
new TestPreparationFailedSubscriber($this), new TestPreparationFailedSubscriber($this),
new TestPreparedSubscriber($this), new TestPreparedSubscriber($this),
new TestPrintedUnexpectedOutputSubscriber($this),
new TestFinishedSubscriber($this), new TestFinishedSubscriber($this),
new TestErroredSubscriber($this), new TestErroredSubscriber($this),
new TestFailedSubscriber($this), new TestFailedSubscriber($this),
@@ -346,11 +334,12 @@ final class JunitXmlLogger
assert($this->currentTestCase !== null); assert($this->currentTestCase !== null);
$buffer = $this->converter->getTestCaseMethodName($event->test()); $buffer = $this->converter->getTestCaseMethodName($event->test()); // pest-changed
$throwable = $event->throwable(); $throwable = $event->throwable();
$buffer .= trim( $buffer .= trim(
$this->converter->getExceptionMessage($throwable).PHP_EOL. $this->converter->getExceptionMessage($throwable).PHP_EOL. // pest-changed
$this->converter->getExceptionDetails($throwable), $this->converter->getExceptionDetails($throwable), // pest-changed
); );
$fault = $this->document->createElement( $fault = $this->document->createElement(
@@ -443,22 +432,25 @@ final class JunitXmlLogger
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
* *
* @phpstan-assert !null $this->currentTestCase * @psalm-assert !null $this->currentTestCase
*/ */
private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void
{ {
$testCase = $this->document->createElement('testcase'); $testCase = $this->document->createElement('testcase');
$test = $event->test(); $test = $event->test();
$file = $this->converter->getTestCaseLocation($test); $file = $this->converter->getTestCaseLocation($test); // pest-added
$testCase->setAttribute('name', $this->converter->getTestCaseMethodName($test));
$testCase->setAttribute('file', $file); $testCase->setAttribute('name', $this->converter->getTestCaseMethodName($test)); // pest-changed
$testCase->setAttribute('file', $file); // pest-changed
if ($test->isTestMethod()) { if ($test->isTestMethod()) {
assert($test instanceof TestMethod); assert($test instanceof TestMethod);
$className = $this->converter->getTrimmedTestClassName($test); //$testCase->setAttribute('line', (string) $test->line()); // pest-removed
$testCase->setAttribute('class', $className); $className = $this->converter->getTrimmedTestClassName($test); // pest-added
$testCase->setAttribute('classname', str_replace('\\', '.', $className)); $testCase->setAttribute('class', $className); // pest-changed
$testCase->setAttribute('classname', str_replace('\\', '.', $className)); // pest-changed
} }
$this->currentTestCase = $testCase; $this->currentTestCase = $testCase;
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/**
* @internal
*/
final readonly class ProgressPrinterOutput implements Printer
{
public function __construct(
private Printer $progressPrinter,
private Printer $outputPrinter,
) {}
public function print(string $buffer): void
{
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();
}
}
+122 -28
View File
@@ -1,47 +1,73 @@
<?php <?php
declare(strict_types=1);
/* /*
* This file is part of PHPUnit. * BSD 3-Clause License
* *
* (c) Sebastian Bergmann <sebastian@phpunit.de> * Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
* *
* For the full copyright and license information, please view the LICENSE * Redistribution and use in source and binary forms, with or without
* file that was distributed with this source code. * modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/ */
namespace PHPUnit\Runner\Filter; namespace PHPUnit\Runner\Filter;
use Exception;
use Pest\Contracts\HasPrintableTestCaseName; use Pest\Contracts\HasPrintableTestCaseName;
use PHPUnit\Framework\SelfDescribing;
use PHPUnit\Framework\Test; use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite; use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\Phpt\TestCase as PhptTestCase;
use RecursiveFilterIterator; use RecursiveFilterIterator;
use RecursiveIterator; use RecursiveIterator;
use function end; use function end;
use function implode;
use function preg_match; use function preg_match;
use function trim; use function sprintf;
use function str_replace;
/** /**
* @extends RecursiveFilterIterator<int, Test, RecursiveIterator<int, Test>>
*
* @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
*/ */
abstract class NameFilterIterator extends RecursiveFilterIterator final class NameFilterIterator extends RecursiveFilterIterator
{ {
private readonly CompiledNameFilter $filter; private ?string $filter = null;
private ?int $filterMin = null;
private ?int $filterMax = null;
/** /**
* @param RecursiveIterator<int, Test> $iterator * @throws Exception
* @param non-empty-string $filter
*/ */
public function __construct(RecursiveIterator $iterator, string $filter) public function __construct(RecursiveIterator $iterator, string $filter)
{ {
parent::__construct($iterator); parent::__construct($iterator);
$this->filter = CompiledNameFilter::from($filter); $this->setFilter($filter);
} }
public function accept(): bool public function accept(): bool
@@ -52,27 +78,95 @@ abstract class NameFilterIterator extends RecursiveFilterIterator
return true; return true;
} }
if ($test instanceof PhptTestCase) { $tmp = $this->describe($test);
return false;
}
if ($test instanceof HasPrintableTestCaseName) { if ($tmp[0] !== '') {
$name = trim( $name = implode('::', $tmp);
$test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName().$test->dataSetAsString()
);
} else { } else {
$name = $test::class.'::'.$test->nameWithDataSet(); $name = $tmp[1];
} }
$accepted = @preg_match($this->filter->regularExpression(), $name, $matches) === 1; $accepted = @preg_match($this->filter, $name, $matches);
if ($accepted && $this->filter->hasDataSetRange()) { if ($accepted && isset($this->filterMax)) {
$set = end($matches); $set = end($matches);
$accepted = $set >= $this->filter->dataSetMinimum() && $set <= $this->filter->dataSetMaximum(); $accepted = $set >= $this->filterMin && $set <= $this->filterMax;
} }
return $this->doAccept($accepted); return (bool) $accepted;
} }
abstract protected function doAccept(bool $result): bool; /**
* @throws Exception
*/
private function setFilter(string $filter): void
{
if (@preg_match($filter, '') === false) {
// Handles:
// * testAssertEqualsSucceeds#4
// * testAssertEqualsSucceeds#4-8
if (preg_match('/^(.*?)#(\d+)(?:-(\d+))?$/', $filter, $matches)) {
if (isset($matches[3]) && $matches[2] < $matches[3]) {
$filter = sprintf(
'%s.*with dataset #(\d+)$',
$matches[1]
);
$this->filterMin = (int) $matches[2];
$this->filterMax = (int) $matches[3];
} else {
$filter = sprintf(
'%s.*with dataset #%s$',
$matches[1],
$matches[2]
);
}
} // Handles:
// * testDetermineJsonError@JSON_ERROR_NONE
// * testDetermineJsonError@JSON.*
elseif (preg_match('/^(.*?)@(.+)$/', $filter, $matches)) {
$filter = sprintf(
'%s.*with dataset "%s"$',
$matches[1],
$matches[2]
);
}
// Escape delimiters in regular expression. Do NOT use preg_quote,
// to keep magic characters.
$filter = sprintf(
'/%s/i',
str_replace(
'/',
'\\/',
$filter
)
);
}
$this->filter = $filter;
}
/**
* @psalm-return array{0: string, 1: string}
*/
private function describe(Test $test): array
{
if ($test instanceof HasPrintableTestCaseName) {
return [
$test::getPrintableTestCaseName(),
$test->getPrintableTestCaseMethodName(),
];
}
if ($test instanceof TestCase) {
return [$test::class, $test->nameWithDataSet()];
}
if ($test instanceof SelfDescribing) {
return ['', $test->toString()];
}
return ['', $test::class];
}
} }
@@ -0,0 +1,198 @@
<?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Runner\ResultCache;
use const DIRECTORY_SEPARATOR;
use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\Runner\DirectoryCannotBeCreatedException;
use PHPUnit\Runner\Exception;
use PHPUnit\Util\Filesystem;
use function array_keys;
use function assert;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function is_array;
use function is_dir;
use function json_decode;
use function json_encode;
use function Pest\version;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class DefaultResultCache implements ResultCache
{
/**
* @var string
*/
private const DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
private readonly string $cacheFilename;
/**
* @psalm-var array<string, TestStatus>
*/
private array $defects = [];
/**
* @psalm-var array<string, TestStatus>
*/
private array $currentDefects = [];
/**
* @psalm-var array<string, float>
*/
private array $times = [];
public function __construct(?string $filepath = null)
{
if ($filepath !== null && is_dir($filepath)) {
$filepath .= DIRECTORY_SEPARATOR.self::DEFAULT_RESULT_CACHE_FILENAME;
}
$this->cacheFilename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_RESULT_CACHE_FILENAME;
}
public function setStatus(string $id, TestStatus $status): void
{
if ($status->isFailure() || $status->isError()) {
$this->currentDefects[$id] = $status;
$this->defects[$id] = $status;
}
}
public function status(string $id): TestStatus
{
return $this->defects[$id] ?? TestStatus::unknown();
}
public function setTime(string $id, float $time): void
{
if (! isset($this->currentDefects[$id])) {
unset($this->defects[$id]);
}
$this->times[$id] = $time;
}
public function time(string $id): float
{
return $this->times[$id] ?? 0.0;
}
public function load(): void
{
$contents = @file_get_contents($this->cacheFilename);
if ($contents === false) {
return;
}
$data = json_decode(
$contents,
true,
);
if ($data === null) {
return;
}
if (! isset($data['version'])) {
return;
}
if ($data['version'] !== $this->cacheVersion()) {
return;
}
assert(isset($data['defects']) && is_array($data['defects']));
assert(isset($data['times']) && is_array($data['times']));
foreach (array_keys($data['defects']) as $test) {
$data['defects'][$test] = TestStatus::from($data['defects'][$test]);
}
$this->defects = $data['defects'];
$this->times = $data['times'];
}
/**
* @throws Exception
*/
public function persist(): void
{
if (! Filesystem::createDirectory(dirname($this->cacheFilename))) {
throw new DirectoryCannotBeCreatedException($this->cacheFilename);
}
$data = [
'version' => $this->cacheVersion(),
'defects' => [],
'times' => $this->times,
];
foreach ($this->defects as $test => $status) {
$data['defects'][$test] = $status->asInt();
}
file_put_contents(
$this->cacheFilename,
json_encode($data),
LOCK_EX
);
}
/**
* Returns the cache version.
*/
private function cacheVersion(): string
{
return 'pest_'.version();
}
}
@@ -1,302 +0,0 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Runner\TestRunHistory;
use const DIRECTORY_SEPARATOR;
use const LOCK_EX;
use const LOCK_UN;
use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\Runner\DirectoryDoesNotExistException;
use PHPUnit\Runner\Exception;
use PHPUnit\Util\Filesystem;
use function array_keys;
use function dirname;
use function fclose;
use function file_get_contents;
use function flock;
use function fopen;
use function ftruncate;
use function fwrite;
use function is_array;
use function is_dir;
use function is_file;
use function is_float;
use function is_int;
use function is_string;
use function json_decode;
use function json_encode;
use function Pest\version;
use function rewind;
use function stream_get_contents;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class DefaultTestRunHistory implements TestRunHistory
{
private const string DEFAULT_FILENAME = '.phpunit.result.cache';
private readonly string $filename;
/**
* @var array<string, TestStatus>
*/
private array $defects = [];
/**
* @var array<string, float>
*/
private array $times = [];
/**
* @var array<string, true>
*/
private array $changedDefects = [];
/**
* @var array<string, true>
*/
private array $changedTimes = [];
public function __construct(?string $filepath = null)
{
if ($filepath !== null && is_dir($filepath)) {
$filepath .= DIRECTORY_SEPARATOR.self::DEFAULT_FILENAME;
}
$this->filename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_FILENAME;
}
public function setStatus(TestRunHistoryId $id, TestStatus $status): void
{
if ($status->isSuccess()) {
return;
}
$this->defects[$id->asString()] = $status;
$this->changedDefects[$id->asString()] = true;
}
public function remove(TestRunHistoryId $id): void
{
unset($this->defects[$id->asString()]);
$this->changedDefects[$id->asString()] = true;
}
public function status(TestRunHistoryId $id): TestStatus
{
return $this->defects[$id->asString()] ?? TestStatus::unknown();
}
public function setTime(TestRunHistoryId $id, float $time): void
{
$this->times[$id->asString()] = $time;
$this->changedTimes[$id->asString()] = true;
}
public function time(TestRunHistoryId $id): float
{
return $this->times[$id->asString()] ?? 0.0;
}
public function mergeWith(self $other): void
{
foreach ($other->defects as $id => $defect) {
$this->defects[$id] = $defect;
$this->changedDefects[$id] = true;
}
foreach ($other->times as $id => $time) {
$this->times[$id] = $time;
$this->changedTimes[$id] = true;
}
}
public function load(): void
{
if (! is_file($this->filename)) {
return;
}
$contents = file_get_contents($this->filename);
if ($contents === false) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
$parsed = $this->parse($contents);
if ($parsed === null) {
return;
}
[$this->defects, $this->times] = $parsed;
$this->changedDefects = [];
$this->changedTimes = [];
}
/**
* @throws Exception
*/
public function persist(): void
{
$this->writeToFile(false);
}
/**
* @throws Exception
*/
public function persistAndPrune(): void
{
$this->writeToFile(true);
}
/**
* @throws Exception
*/
private function writeToFile(bool $prune): void
{
if (! Filesystem::createDirectory(dirname($this->filename))) {
throw new DirectoryDoesNotExistException(dirname($this->filename));
}
$handle = fopen($this->filename, 'c+');
if ($handle === false) {
// @codeCoverageIgnoreStart
return;
// @codeCoverageIgnoreEnd
}
flock($handle, LOCK_EX);
if ($prune) {
$defects = [];
foreach ($this->defects as $id => $status) {
if (isset($this->changedDefects[$id])) {
$defects[$id] = $status;
}
}
$times = [];
foreach ($this->times as $id => $time) {
if (isset($this->changedTimes[$id]) || isset($this->changedDefects[$id])) {
$times[$id] = $time;
}
}
} else {
$parsed = $this->parse((string) stream_get_contents($handle));
if ($parsed !== null) {
[$defects, $times] = $parsed;
foreach (array_keys($this->changedDefects) as $id) {
if (isset($this->defects[$id])) {
$defects[$id] = $this->defects[$id];
} else {
unset($defects[$id]);
}
}
foreach ($this->times as $id => $time) {
if (isset($this->changedTimes[$id])) {
$times[$id] = $time;
}
}
} else {
$defects = $this->defects;
$times = $this->times;
}
}
$data = [
'version' => $this->version(),
'defects' => [],
'times' => $times,
];
foreach ($defects as $test => $status) {
$data['defects'][$test] = $status->asInt();
}
$json = json_encode($data);
if ($json !== false) {
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, $json);
}
flock($handle, LOCK_UN);
fclose($handle);
}
/**
* @return ?array{0: array<string, TestStatus>, 1: array<string, float>}
*/
private function parse(string $contents): ?array
{
$data = json_decode(
$contents,
true,
);
if (! is_array($data)) {
return null;
}
if (! isset($data['version']) || $data['version'] !== $this->version()) {
return null;
}
if (! isset($data['defects'], $data['times']) || ! is_array($data['defects']) || ! is_array($data['times'])) {
return null;
}
$defects = [];
foreach ($data['defects'] as $test => $status) {
if (! is_string($test) || ! is_int($status)) {
continue;
}
$defects[$test] = TestStatus::from($status);
}
$times = [];
foreach ($data['times'] as $test => $time) {
if (! is_string($test) || (! is_float($time) && ! is_int($time))) {
continue;
}
$times[$test] = (float) $time;
}
return [$defects, $times];
}
private function version(): string
{
return 'pest_'.version();
}
}
+34 -8
View File
@@ -1,18 +1,48 @@
<?php <?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1); declare(strict_types=1);
namespace PHPUnit\Runner; namespace PHPUnit\Runner;
use Exception; use Exception;
use Pest\Contracts\HasPrintableTestCaseName; use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Panic;
use Pest\TestCases\IgnorableTestCase; use Pest\TestCases\IgnorableTestCase;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionClass; use ReflectionClass;
use ReflectionException; use ReflectionException;
use Throwable;
use function array_diff; use function array_diff;
use function array_values; use function array_values;
@@ -56,13 +86,9 @@ final class TestSuiteLoader
$suiteClassName = $this->classNameFromFileName($suiteClassFile); $suiteClassName = $this->classNameFromFileName($suiteClassFile);
(static function () use ($suiteClassFile) { (static function () use ($suiteClassFile) {
try { include_once $suiteClassFile;
include_once $suiteClassFile;
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile); TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
} catch (Throwable $e) {
Panic::with($e);
}
})(); })();
$loadedClasses = array_values( $loadedClasses = array_values(
-389
View File
@@ -1,389 +0,0 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Runner;
use PHPUnit\Framework\DataProviderTestSuite;
use PHPUnit\Framework\IterativeTestSuite;
use PHPUnit\Framework\Reorderable;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\TestRunHistory\NullTestRunHistory;
use PHPUnit\Runner\TestRunHistory\TestRunHistory;
use PHPUnit\Runner\TestRunHistory\TestRunHistoryId;
use function array_diff;
use function array_merge;
use function array_reverse;
use function array_splice;
use function assert;
use function count;
use function in_array;
use function max;
use function shuffle;
use function usort;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class TestSuiteSorter
{
public const int ORDER_DEFAULT = 0;
public const int ORDER_RANDOMIZED = 1;
public const int ORDER_REVERSED = 2;
public const int ORDER_DEFECTS_FIRST = 3;
public const int ORDER_DURATION_ASCENDING = 4;
public const int ORDER_SIZE_ASCENDING = 5;
public const int ORDER_DURATION_DESCENDING = 6;
public const int ORDER_SIZE_DESCENDING = 7;
/**
* @var non-empty-array<non-empty-string, positive-int>
*/
private const array SIZE_SORT_WEIGHT = [
'small' => 1,
'medium' => 2,
'large' => 3,
'unknown' => 4,
];
/**
* @var array<string, int> Associative array of (string => DEFECT_SORT_WEIGHT) elements
*/
private array $defectSortOrder = [];
private readonly TestRunHistory $testRunHistory;
public function __construct(?TestRunHistory $testRunHistory = null)
{
$this->testRunHistory = $testRunHistory ?? new NullTestRunHistory;
}
/**
* @throws Exception
*/
public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
$allowedOrders = [
self::ORDER_DEFAULT,
self::ORDER_REVERSED,
self::ORDER_RANDOMIZED,
self::ORDER_DURATION_ASCENDING,
self::ORDER_SIZE_ASCENDING,
self::ORDER_DURATION_DESCENDING,
self::ORDER_SIZE_DESCENDING,
];
if (! in_array($order, $allowedOrders, true)) {
// @codeCoverageIgnoreStart
throw new InvalidOrderException;
// @codeCoverageIgnoreEnd
}
$allowedOrderDefects = [
self::ORDER_DEFAULT,
self::ORDER_DEFECTS_FIRST,
];
if (! in_array($orderDefects, $allowedOrderDefects, true)) {
// @codeCoverageIgnoreStart
throw new InvalidOrderException;
// @codeCoverageIgnoreEnd
}
if ($suite instanceof IterativeTestSuite) {
return;
}
if ($suite instanceof TestSuite) {
foreach ($suite as $_suite) {
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
}
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
$this->addSuiteToDefectSortOrder($suite);
}
$this->sort($suite, $order, $resolveDependencies, $orderDefects);
}
}
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
$tests = $suite->tests();
if ($tests === []) {
return;
}
if ($order === self::ORDER_REVERSED) {
$tests = $this->reverse($tests);
} elseif ($order === self::ORDER_RANDOMIZED) {
$tests = $this->randomize($tests);
} elseif ($order === self::ORDER_DURATION_ASCENDING) {
$tests = $this->sortByDuration($tests);
} elseif ($order === self::ORDER_DURATION_DESCENDING) {
$tests = $this->sortByDurationDescending($tests);
} elseif ($order === self::ORDER_SIZE_ASCENDING) {
$tests = $this->sortBySize($tests);
} elseif ($order === self::ORDER_SIZE_DESCENDING) {
$tests = $this->sortBySizeDescending($tests);
}
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
$tests = $this->sortDefectsFirst($tests);
}
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
/** @noinspection PhpParamsInspection */
/** @phpstan-ignore argument.type */
$tests = $this->resolveDependencies($tests);
}
$suite->setTests($tests);
}
private function addSuiteToDefectSortOrder(TestSuite $suite): void
{
$max = 0;
foreach ($suite->tests() as $test) {
assert($test instanceof Reorderable);
$sortId = $test->sortId();
if (! isset($this->defectSortOrder[$sortId])) {
$this->defectSortOrder[$sortId] = $this->testRunHistory->status(TestRunHistoryId::fromReorderable($test))->sortWeight();
}
$max = max($max, $this->defectSortOrder[$sortId]);
}
$this->defectSortOrder[$suite->sortId()] = $max;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function reverse(array $tests): array
{
return array_reverse($tests);
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function randomize(array $tests): array
{
shuffle($tests);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortDefectsFirst(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortByDuration(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDuration($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortByDurationDescending(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDuration($right, $left),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortBySize(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpSize($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortBySizeDescending(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpSize($right, $left),
);
return $tests;
}
private function cmpDefectPriorityAndTime(Test $a, Test $b): int
{
assert($a instanceof Reorderable);
assert($b instanceof Reorderable);
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
return $priorityB <=> $priorityA;
}
private function cmpDuration(Test $a, Test $b): int
{
return $this->durationWeight($a) <=> $this->durationWeight($b);
}
private function durationWeight(Test $test): float
{
if ($test instanceof TestSuite) {
$sum = 0.0;
foreach ($test->tests() as $inner) {
$sum += $this->durationWeight($inner);
}
return $sum;
}
if ($test instanceof Reorderable) {
return $this->testRunHistory->time(TestRunHistoryId::fromReorderable($test));
}
return 0.0;
}
private function cmpSize(Test $a, Test $b): int
{
return $this->sizeWeight($a) <=> $this->sizeWeight($b);
}
/**
* @return positive-int
*/
private function sizeWeight(Test $test): int
{
if ($test instanceof TestCase || $test instanceof DataProviderTestSuite) {
return self::SIZE_SORT_WEIGHT[$test->size()->asString()];
}
if ($test instanceof TestSuite) {
$max = 0;
foreach ($test->tests() as $inner) {
$weight = $this->sizeWeight($inner);
if ($weight > $max) {
$max = $weight;
}
}
if ($max > 0) {
return $max;
}
}
return self::SIZE_SORT_WEIGHT['unknown'];
}
/**
* @param list<TestCase> $tests
* @return list<TestCase>
*/
private function resolveDependencies(array $tests): array
{
if (! $this->anyTestHasDependencies($tests)) {
return $tests;
}
$newTestOrder = [];
$i = 0;
$provided = [];
while ($tests !== [] && $i < count($tests)) {
if (array_diff($tests[$i]->requires(), $provided) === []) {
$provided = array_merge($provided, $tests[$i]->provides());
$newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1));
$i = 0;
} else {
$i++;
}
}
return array_merge($newTestOrder, $tests);
}
/**
* @param iterable<Test> $tests
*/
private function anyTestHasDependencies(iterable $tests): bool
{
foreach ($tests as $test) {
if ($test instanceof TestSuite) {
if ($this->anyTestHasDependencies($test->tests())) {
return true;
}
continue;
}
if ($test instanceof TestCase && $test->requires() !== []) {
return true;
}
}
return false;
}
}
@@ -1,5 +1,37 @@
<?php <?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1); declare(strict_types=1);
/* /*
@@ -13,8 +45,6 @@ declare(strict_types=1);
namespace PHPUnit\TextUI\Command; namespace PHPUnit\TextUI\Command;
use const PHP_EOL;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\Configuration\Configuration; use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\Configuration\NoCoverageCacheDirectoryException; use PHPUnit\TextUI\Configuration\NoCoverageCacheDirectoryException;
@@ -25,11 +55,11 @@ use SebastianBergmann\Timer\Timer;
/** /**
* @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
*/ */
final readonly class WarmCodeCoverageCacheCommand implements Command final class WarmCodeCoverageCacheCommand implements Command
{ {
private Configuration $configuration; private readonly Configuration $configuration;
private CodeCoverageFilterRegistry $codeCoverageFilterRegistry; private readonly CodeCoverageFilterRegistry $codeCoverageFilterRegistry;
public function __construct(Configuration $configuration, CodeCoverageFilterRegistry $codeCoverageFilterRegistry) public function __construct(Configuration $configuration, CodeCoverageFilterRegistry $codeCoverageFilterRegistry)
{ {
@@ -46,16 +76,16 @@ final readonly class WarmCodeCoverageCacheCommand implements Command
if (! $this->configuration->hasCoverageCacheDirectory()) { if (! $this->configuration->hasCoverageCacheDirectory()) {
return Result::from( return Result::from(
'Cache for static analysis has not been configured'.PHP_EOL, 'Cache for static analysis has not been configured'.PHP_EOL,
Result::FAILURE, Result::FAILURE
); );
} }
$this->codeCoverageFilterRegistry->init($this->configuration, true); $this->codeCoverageFilterRegistry->init($this->configuration);
if (! $this->codeCoverageFilterRegistry->configured()) { if (! $this->codeCoverageFilterRegistry->configured()) {
return Result::from( return Result::from(
'Filter for code coverage has not been configured'.PHP_EOL, 'Filter for code coverage has not been configured'.PHP_EOL,
Result::FAILURE, Result::FAILURE
); );
} }
@@ -66,7 +96,7 @@ final readonly class WarmCodeCoverageCacheCommand implements Command
$this->configuration->coverageCacheDirectory(), $this->configuration->coverageCacheDirectory(),
! $this->configuration->disableCodeCoverageIgnore(), ! $this->configuration->disableCodeCoverageIgnore(),
$this->configuration->ignoreDeprecatedCodeUnitsFromCodeCoverage(), $this->configuration->ignoreDeprecatedCodeUnitsFromCodeCoverage(),
$this->codeCoverageFilterRegistry->get(), $this->codeCoverageFilterRegistry->get()
); );
return Result::from(); return Result::from();
@@ -1,5 +1,37 @@
<?php <?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1); declare(strict_types=1);
/* /*
@@ -19,22 +51,30 @@ 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 to allow Pest Parallel to show todo items in the progress output.
*/ */
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{ {
/**
* Notifies the printer that a test was skipped.
*/
public function notify(Skipped $event): void public function notify(Skipped $event): void
{ {
if ($event->message() === '__TODO__') { if (str_contains($event->message(), '__TODO__')) {
$this->printTodoItem(); $this->printTodoItem();
} }
$this->printer()->testSkipped(); $this->printer()->testSkipped();
} }
/**
* Prints a "T" to the standard PHPUnit output to indicate a todo item.
*/
private function printTodoItem(): void private function printTodoItem(): void
{ {
$mirror = new ReflectionClass($this->printer()); $mirror = new ReflectionClass($this->printer());
$printProgress = $mirror->getMethod('printProgress'); $printerMirror = $mirror->getMethod('printProgress');
$printProgress->invoke($this->printer(), 'T'); $printerMirror->invoke($this->printer(), 'T');
} }
} }
+46 -28
View File
@@ -1,6 +1,39 @@
<?php <?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1); declare(strict_types=1);
/* /*
* This file is part of PHPUnit. * This file is part of PHPUnit.
* *
@@ -13,7 +46,6 @@ declare(strict_types=1);
namespace PHPUnit\TextUI; namespace PHPUnit\TextUI;
use Pest\Plugins\Only; use Pest\Plugins\Only;
use Pest\Runner\Filter\EnsureTestCaseIsInitiatedFilter;
use PHPUnit\Event; use PHPUnit\Event;
use PHPUnit\Framework\TestSuite; use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\Filter\Factory; use PHPUnit\Runner\Filter\Factory;
@@ -25,7 +57,7 @@ use function array_map;
/** /**
* @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
*/ */
final readonly class TestSuiteFilterProcessor final class TestSuiteFilterProcessor
{ {
/** /**
* @throws Event\RuntimeException * @throws Event\RuntimeException
@@ -35,35 +67,27 @@ final readonly class TestSuiteFilterProcessor
{ {
$factory = new Factory; $factory = new Factory;
// @phpstan-ignore-next-line
(fn () => $this->filters[] = [
'className' => EnsureTestCaseIsInitiatedFilter::class,
'argument' => '',
])->call($factory);
if (! $configuration->hasFilter() && if (! $configuration->hasFilter() &&
! $configuration->hasGroups() && ! $configuration->hasGroups() &&
! $configuration->hasExcludeGroups() && ! $configuration->hasExcludeGroups() &&
! $configuration->hasExcludeFilter() &&
! $configuration->hasTestsCovering() && ! $configuration->hasTestsCovering() &&
! $configuration->hasTestsUsing() && ! $configuration->hasTestsUsing() &&
! Only::isEnabled()) { ! Only::isEnabled()
$suite->injectFilter($factory); ) {
return; return;
} }
if ($configuration->hasExcludeGroups()) { if ($configuration->hasExcludeGroups()) {
$factory->addExcludeGroupFilter( $factory->addExcludeGroupFilter(
$configuration->excludeGroups(), $configuration->excludeGroups()
); );
} }
if (Only::isEnabled()) { if (Only::isEnabled()) {
$factory->addIncludeGroupFilter([Only::group()]); $factory->addIncludeGroupFilter(['__pest_only']);
} elseif ($configuration->hasGroups()) { } elseif ($configuration->hasGroups()) {
$factory->addIncludeGroupFilter( $factory->addIncludeGroupFilter(
$configuration->groups(), $configuration->groups()
); );
} }
@@ -71,8 +95,8 @@ final readonly class TestSuiteFilterProcessor
$factory->addIncludeGroupFilter( $factory->addIncludeGroupFilter(
array_map( array_map(
static fn (string $name): string => '__phpunit_covers_'.$name, static fn (string $name): string => '__phpunit_covers_'.$name,
$configuration->testsCovering(), $configuration->testsCovering()
), )
); );
} }
@@ -80,27 +104,21 @@ final readonly class TestSuiteFilterProcessor
$factory->addIncludeGroupFilter( $factory->addIncludeGroupFilter(
array_map( array_map(
static fn (string $name): string => '__phpunit_uses_'.$name, static fn (string $name): string => '__phpunit_uses_'.$name,
$configuration->testsUsing(), $configuration->testsUsing()
), )
);
}
if ($configuration->hasExcludeFilter()) {
$factory->addExcludeNameFilter(
$configuration->excludeFilter(),
); );
} }
if ($configuration->hasFilter()) { if ($configuration->hasFilter()) {
$factory->addIncludeNameFilter( $factory->addNameFilter(
$configuration->filter(), $configuration->filter()
); );
} }
$suite->injectFilter($factory); $suite->injectFilter($factory);
Event\Facade::emitter()->testSuiteFiltered( Event\Facade::emitter()->testSuiteFiltered(
Event\TestSuite\TestSuiteBuilder::from($suite), Event\TestSuite\TestSuiteBuilder::from($suite)
); );
} }
} }
-121
View File
@@ -1,121 +0,0 @@
parameters:
ignoreErrors:
-
message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: src/Concerns/Expectable.php
-
message: '#^Trait Pest\\Concerns\\Logging\\WritesToConsole is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: src/Concerns/Logging/WritesToConsole.php
-
message: '#^Trait Pest\\Concerns\\Testable is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: src/Concerns/Testable.php
-
message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\<TAndValue\> but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\<TAndValue of mixed\>\.$#'
identifier: return.type
count: 1
path: src/Expectation.php
-
message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$each contains generic class Pest\\Expectations\\EachExpectation but does not specify its types\: TValue$#'
identifier: missingType.generics
count: 1
path: src/Expectation.php
-
message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$not contains generic class Pest\\Expectations\\OppositeExpectation but does not specify its types\: TValue$#'
identifier: missingType.generics
count: 1
path: src/Expectation.php
-
message: '#^Parameter \#2 \$newScope of method Closure\:\:bindTo\(\) expects ''static''\|class\-string\|object\|null, string given\.$#'
identifier: argument.type
count: 1
path: src/Expectation.php
-
message: '#^Function expect\(\) should return Pest\\Expectation\<TValue\|null\> but returns Pest\\Expectation\<TValue\|null\>\.$#'
identifier: return.type
count: 1
path: src/Functions.php
-
message: '#^Parameter \#1 \$argv of method PHPUnit\\TextUI\\Application\:\:run\(\) expects list\<string\>, array\<int, string\> given\.$#'
identifier: argument.type
count: 1
path: src/Kernel.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:__toString\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Mixins/Expectation.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:toArray\(\)\.$#'
identifier: method.notFound
count: 4
path: src/Mixins/Expectation.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:toSnapshot\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Mixins/Expectation.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:toString\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Mixins/Expectation.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 2
path: src/Mixins/Expectation.php
-
message: '#^PHPDoc tag @var with type callable\(\)\: bool is not subtype of native type Closure\|null\.$#'
identifier: varTag.nativeType
count: 1
path: src/PendingCalls/TestCall.php
-
message: '#^Parameter \#4 \$testErroredEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\AfterLastTestMethodErrored\|PHPUnit\\Event\\Test\\BeforeFirstTestMethodErrored\|PHPUnit\\Event\\Test\\Errored\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestSuite\\Skipped\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#8 \$testSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\Skipped\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#9 \$testMarkedIncompleteEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\MarkedIncomplete\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Property Pest\\Plugins\\Parallel\\Paratest\\WrapperRunner\:\:\$pending \(list\<non\-empty\-string\>\) does not accept array\<int, non\-empty\-string\>\.$#'
identifier: assign.propertyType
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-5
View File
@@ -1,5 +0,0 @@
services:
-
class: Pest\PHPStan\HigherOrderExpectationTypeExtension
tags:
- phpstan.broker.expressionTypeResolverExtension
+8 -5
View File
@@ -1,11 +1,14 @@
includes: includes:
- phpstan-baseline.neon - vendor/phpstan/phpstan-strict-rules/rules.neon
- phpstan-pest-extension.neon - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
- vendor/pestphp/pest-plugin-phpstan/extension.neon
parameters: parameters:
level: 7 level: max
paths: paths:
- src - src
reportUnmatchedIgnoredErrors: false checkMissingIterableValueType: true
reportUnmatchedIgnoredErrors: true
ignoreErrors:
- "#type mixed is not subtype of native#"
+1 -3
View File
@@ -16,11 +16,9 @@
<testsuites> <testsuites>
<testsuite name="default"> <testsuite name="default">
<directory suffix=".php">./tests</directory> <directory suffix=".php">./tests</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>
-8
View File
@@ -1,8 +0,0 @@
{
"exclude": [
"tests/Fixtures/Suites"
],
"rules": {
"Pint/phpdoc_type_annotations_only": true
}
}
+23 -54
View File
@@ -2,61 +2,30 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Rector\Rules\UseToMatchArrayRector; use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector;
use Pest\Rector\Set\PestSetList;
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig; use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector; use Rector\Set\ValueObject\LevelSetList;
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector; use Rector\Set\ValueObject\SetList;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
return RectorConfig::configure() return static function (RectorConfig $rectorConfig): void {
->withPaths([ $rectorConfig->paths([
__DIR__.'/src', __DIR__.'/src',
__DIR__.'/tests', ]);
])
->withSets([ $rectorConfig->rules([
PestSetList::CODING_STYLE, InlineConstructorDefaultToPropertyRector::class,
]) ]);
->withSkip([
$rectorConfig->skip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php', __DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
__DIR__.'/tests/Fixtures/Arch', ]);
__DIR__.'/tests/Fixtures/Suites',
ReturnNeverTypeRector::class, $rectorConfig->sets([
ArrowFunctionDelegatingCallToFirstClassCallableRector::class, LevelSetList::UP_TO_PHP_81,
NarrowObjectReturnTypeRector::class, SetList::CODE_QUALITY,
RemoveParentDelegatingConstructorRector::class, SetList::DEAD_CODE,
RemoveDuplicatedReturnSelfDocblockRector::class, SetList::EARLY_RETURN,
RemoveUselessUnionReturnDocblockRector::class, SetList::TYPE_DECLARATION,
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [ SetList::PRIVATIZATION,
__DIR__.'/src/Expectations/HigherOrderExpectation.php', ]);
], };
UseToMatchArrayRector::class,
RemoveEmptyClassMethodRector::class => [
__DIR__.'/tests',
],
RemoveUnusedConstructorParamRector::class => [
__DIR__.'/tests',
],
RemoveUnusedPrivatePropertyRector::class => [
__DIR__.'/tests',
],
AddArrowFunctionReturnTypeRector::class => [
__DIR__.'/tests',
],
])
->withPreparedSets(
deadCode: true,
codeQuality: true,
typeDeclarations: true,
privatization: true,
earlyReturn: true,
)
->withPhpSets();
-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Default">
<directory>tests/</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
<directory>src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
</php>
</phpunit>
@@ -1,196 +0,0 @@
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
---
@php
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
@endphp
# Pest Testing 5
## Documentation
Use `search-docs` for detailed Pest 5 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
@boostsnippet("Basic Pest Test Example", "php")
it('is true', function () {
expect(true)->toBeTrue();
});
@endboostsnippet
### Running Tests
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
@boostsnippet("Pest Response Assertion", "php")
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
@endboostsnippet
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
@boostsnippet("Pest Dataset Example", "php")
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
@endboostsnippet
## Pest 5 Features
| Feature | Purpose |
|---------|---------|
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
| Time-Balanced Sharding | Split tests across CI shards by execution time |
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Architecture Testing | Enforce code conventions |
### Tia (Test Impact Analysis)
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
@boostsnippet("Tia Example", "shell")
./vendor/bin/pest --parallel --tia
@endboostsnippet
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
### New Validation Expectations
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
@boostsnippet("Pest 5 Validation Expectations", "php")
expect('nuno@pestphp.com')->toBeEmail();
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
expect('192.168.1.1')->toBeIpAddress();
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
expect('example.com')->toBeHostname();
expect('example.co.uk')->toBeDomain();
expect('Zm9vYmFy')->toBeBase64();
expect('deadbeef')->toBeHexadecimal();
@endboostsnippet
### Time-Balanced Sharding
Distribute tests across CI shards by execution time rather than count:
@boostsnippet("Pest Sharding Example", "shell")
./vendor/bin/pest --update-shards
./vendor/bin/pest --shard=1/4
@endboostsnippet
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
@boostsnippet("Pest Browser Test Example", "php")
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
@endboostsnippet
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
@boostsnippet("Pest Smoke Testing Example", "php")
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
@endboostsnippet
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Architecture Testing
@boostsnippet("Architecture Test Example", "php")
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
@endboostsnippet
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
-2
View File
@@ -5,8 +5,6 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], 'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
}; };
?> ?>
@@ -1,22 +0,0 @@
<div class="mx-2 mb-1">
<p>
<span>Using the <span class="text-yellow font-bold">visit()</span> function requires the Pest Plugin Browser to be installed.</span>
<span class="ml-1 text-yellow font-bold">Run:</span>
</p>
<div>
<span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
</div>
<div>
<span class="text-gray mr-1">- </span>
<span>npm install playwright@latest</span>
</div>
<div>
<span class="text-gray mr-1">- </span>
<span>npx playwright install</span>
</div>
</div>
-61
View File
@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
abstract class AbstractPreset // @pest-arch-ignore-line
{
/**
* @var array<int, Expectation<mixed>|ArchExpectation>
*/
protected array $expectations = [];
/**
* @param array<int, string> $userNamespaces
*/
public function __construct(
private readonly array $userNamespaces,
) {
//
}
/**
* @internal
*/
abstract public function execute(): void;
/**
* @param array<int, string>|string $targetsOrDependencies
*/
final public function ignoring(array|string $targetsOrDependencies): void
{
$this->expectations = array_map(
fn (ArchExpectation|Expectation $expectation): Expectation|ArchExpectation => $expectation instanceof ArchExpectation ? $expectation->ignoring($targetsOrDependencies) : $expectation,
$this->expectations,
);
}
/**
* @param callable(Expectation<string>): ArchExpectation ...$callbacks
*/
final public function eachUserNamespace(callable ...$callbacks): void
{
foreach ($this->userNamespaces as $namespace) {
foreach ($callbacks as $callback) {
$this->expectations[] = $callback(expect($namespace));
}
}
}
final public function flush(): void
{
$this->expectations = [];
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Closure;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Custom extends AbstractPreset
{
/**
* @param array<int, string> $userNamespaces
* @param Closure(array<int, string>): array<Expectation<mixed>|ArchExpectation> $execute
*/
public function __construct(
private readonly array $userNamespaces,
private readonly string $name,
private readonly Closure $execute,
) {
parent::__construct($userNamespaces);
}
public function name(): string
{
return $this->name;
}
public function execute(): void
{
$this->expectations = ($this->execute)($this->userNamespaces);
}
}
-177
View File
@@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Throwable;
/**
* @internal
*/
final class Laravel extends AbstractPreset
{
public function execute(): void
{
$this->expectations[] = expect('App\Traits')
->toBeTraits();
$this->expectations[] = expect('App\Concerns')
->toBeTraits();
$this->expectations[] = expect('App')
->not->toBeEnums()
->ignoring('App\Enums');
$this->expectations[] = expect('App\Enums')
->toBeEnums()
->ignoring('App\Enums\Concerns');
$this->expectations[] = expect('App\Features')
->toBeClasses()
->ignoring('App\Features\Concerns');
$this->expectations[] = expect('App\Features')
->toHaveMethod('resolve')
->ignoring('App\Features\Concerns');
$this->expectations[] = expect('App\Exceptions')
->classes()
->toImplement('Throwable')
->ignoring('App\Exceptions\Handler');
$this->expectations[] = expect('App')
->not->toImplement(Throwable::class)
->ignoring('App\Exceptions');
$this->expectations[] = expect('App\Http\Middleware')
->classes()
->toHaveMethod('handle');
$this->expectations[] = expect('App\Models')
->classes()
->toExtend('Illuminate\Database\Eloquent\Model')
->ignoring('App\Models\Scopes');
$this->expectations[] = expect('App\Models')
->classes()
->not->toHaveSuffix('Model');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Database\Eloquent\Model')
->ignoring('App\Models');
$this->expectations[] = expect('App\Http\Requests')
->classes()
->toHaveSuffix('Request');
$this->expectations[] = expect('App\Http\Requests')
->classes()
->toExtend('Illuminate\Foundation\Http\FormRequest');
$this->expectations[] = expect('App\Http\Requests')
->toHaveMethod('rules');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Foundation\Http\FormRequest')
->ignoring('App\Http\Requests');
$this->expectations[] = expect('App\Console\Commands')
->classes()
->toHaveSuffix('Command');
$this->expectations[] = expect('App\Console\Commands')
->classes()
->toExtend('Illuminate\Console\Command');
$this->expectations[] = expect('App\Console\Commands')
->classes()
->toHaveMethod('handle');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Console\Command')
->ignoring('App\Console\Commands');
$this->expectations[] = expect('App\Mail')
->classes()
->toExtend('Illuminate\Mail\Mailable');
$this->expectations[] = expect('App\Mail')
->classes()
->toImplement('Illuminate\Contracts\Queue\ShouldQueue');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Mail\Mailable')
->ignoring('App\Mail');
$this->expectations[] = expect('App\Jobs')
->classes()
->toImplement('Illuminate\Contracts\Queue\ShouldQueue');
$this->expectations[] = expect('App\Jobs')
->classes()
->toHaveMethod('handle');
$this->expectations[] = expect('App\Listeners')
->toHaveMethod('handle');
$this->expectations[] = expect('App\Notifications')
->classes()
->toExtend('Illuminate\Notifications\Notification');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Notifications\Notification')
->ignoring('App\Notifications');
$this->expectations[] = expect('App\Providers')
->toHaveSuffix('ServiceProvider');
$this->expectations[] = expect('App\Providers')
->classes()
->toExtend('Illuminate\Support\ServiceProvider');
$this->expectations[] = expect('App\Providers')
->not->toBeUsed();
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Support\ServiceProvider')
->ignoring('App\Providers');
$this->expectations[] = expect('App')
->not->toHaveSuffix('ServiceProvider')
->ignoring('App\Providers');
$this->expectations[] = expect('App')
->not->toHaveSuffix('Controller')
->ignoring('App\Http\Controllers');
$this->expectations[] = expect('App\Http\Controllers')
->classes()
->toHaveSuffix('Controller');
$this->expectations[] = expect('App\Http')
->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
$this->expectations[] = expect('App\Http\Controllers')
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
$this->expectations[] = expect([
'dd',
'ddd',
'dump',
'env',
'exit',
'ray',
])->not->toBeUsed();
$this->expectations[] = expect('App\Policies')
->classes()
->toHaveSuffix('Policy');
$this->expectations[] = expect('App\Attributes')
->classes()
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute')
->toHaveMethod('resolve');
}
}
-90
View File
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
/**
* @internal
*/
final class Php extends AbstractPreset
{
public function execute(): void
{
$this->expectations[] = expect([
'debug_zval_dump',
'debug_backtrace',
'debug_print_backtrace',
'dump',
'ray',
'ds',
'die',
'goto',
'global',
'var_dump',
'phpinfo',
'echo',
'ereg',
'eregi',
'mysql_connect',
'mysql_pconnect',
'mysql_query',
'mysql_select_db',
'mysql_fetch_array',
'mysql_fetch_assoc',
'mysql_fetch_object',
'mysql_fetch_row',
'mysql_num_rows',
'mysql_affected_rows',
'mysql_free_result',
'mysql_insert_id',
'mysql_error',
'mysql_real_escape_string',
'print',
'print_r',
'var_export',
'xdebug_break',
'xdebug_call_class',
'xdebug_call_file',
'xdebug_call_int',
'xdebug_call_line',
'xdebug_code_coverage_started',
'xdebug_connect_to_client',
'xdebug_debug_zval',
'xdebug_debug_zval_stdout',
'xdebug_dump_superglobals',
'xdebug_get_code_coverage',
'xdebug_get_collected_errors',
'xdebug_get_function_count',
'xdebug_get_function_stack',
'xdebug_get_gc_run_count',
'xdebug_get_gc_total_collected_roots',
'xdebug_get_gcstats_filename',
'xdebug_get_headers',
'xdebug_get_monitored_functions',
'xdebug_get_profiler_filename',
'xdebug_get_stack_depth',
'xdebug_get_tracefile_name',
'xdebug_info',
'xdebug_is_debugger_active',
'xdebug_memory_usage',
'xdebug_notify',
'xdebug_peak_memory_usage',
'xdebug_print_function_stack',
'xdebug_set_filter',
'xdebug_start_code_coverage',
'xdebug_start_error_collection',
'xdebug_start_function_monitor',
'xdebug_start_gcstats',
'xdebug_start_trace',
'xdebug_stop_code_coverage',
'xdebug_stop_error_collection',
'xdebug_stop_function_monitor',
'xdebug_stop_gcstats',
'xdebug_stop_trace',
'xdebug_time_index',
'xdebug_var_dump',
'trap',
])->not->toBeUsed();
}
}
-23
View File
@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Relaxed extends AbstractPreset
{
public function execute(): void
{
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->not->toUseStrictTypes(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeFinal(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHavePrivateMethods(),
);
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
/**
* @internal
*/
final class Security extends AbstractPreset
{
public function execute(): void
{
$this->expectations[] = expect([
'md5',
'sha1',
'uniqid',
'rand',
'mt_rand',
'tempnam',
'str_shuffle',
'shuffle',
'array_rand',
'eval',
'exec',
'shell_exec',
'system',
'passthru',
'create_function',
'unserialize',
'extract',
'mb_parse_str',
'dl',
'assert',
])->not->toBeUsed();
}
}
-30
View File
@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Strict extends AbstractPreset
{
public function execute(): void
{
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHaveProtectedMethods(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeAbstract(),
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictTypes(),
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictEquality(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->toBeFinal(),
);
$this->expectations[] = expect([
'sleep',
'usleep',
])->not->toBeUsed();
}
}
+6 -1
View File
@@ -13,9 +13,11 @@ use PHPUnit\Util\ExcludeList;
final class BootExcludeList implements Bootstrapper final class BootExcludeList implements Bootstrapper
{ {
/** /**
* The directories to exclude.
*
* @var array<int, non-empty-string> * @var array<int, non-empty-string>
*/ */
private const array EXCLUDE_LIST = [ private const EXCLUDE_LIST = [
'bin', 'bin',
'overrides', 'overrides',
'resources', 'resources',
@@ -23,6 +25,9 @@ final class BootExcludeList implements Bootstrapper
'stubs', 'stubs',
]; ];
/**
* Boots the "exclude list" for PHPUnit to ignore Pest files.
*/
public function boot(): void public function boot(): void
{ {
$baseDirectory = dirname(__DIR__, 2); $baseDirectory = dirname(__DIR__, 2);
+10 -7
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Bootstrappers; namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper; use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\FatalException;
use Pest\Support\DatasetInfo; use Pest\Support\DatasetInfo;
use Pest\Support\Str; use Pest\Support\Str;
use Pest\TestSuite; use Pest\TestSuite;
@@ -21,9 +20,11 @@ use function Pest\testDirectory;
final class BootFiles implements Bootstrapper final class BootFiles implements Bootstrapper
{ {
/** /**
* The structure of the tests directory.
*
* @var array<int, string> * @var array<int, string>
*/ */
private const array STRUCTURE = [ private const STRUCTURE = [
'Expectations', 'Expectations',
'Expectations.php', 'Expectations.php',
'Helpers', 'Helpers',
@@ -31,15 +32,14 @@ final class BootFiles implements Bootstrapper
'Pest.php', 'Pest.php',
]; ];
/**
* Boots the structure of the tests directory.
*/
public function boot(): void public function boot(): void
{ {
$rootPath = TestSuite::getInstance()->rootPath; $rootPath = TestSuite::getInstance()->rootPath;
$testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory(); $testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory();
if (! is_dir($testsPath)) {
throw new FatalException(sprintf('The test directory [%s] does not exist.', $testsPath));
}
foreach (self::STRUCTURE as $filename) { foreach (self::STRUCTURE as $filename) {
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename); $filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
@@ -62,6 +62,9 @@ final class BootFiles implements Bootstrapper
$this->bootDatasets($testsPath); $this->bootDatasets($testsPath);
} }
/**
* Loads, if possible, the given file.
*/
private function load(string $filename): void private function load(string $filename): void
{ {
if (! Str::endsWith($filename, '.php')) { if (! Str::endsWith($filename, '.php')) {
@@ -75,7 +78,7 @@ final class BootFiles implements Bootstrapper
private function bootDatasets(string $testsPath): void private function bootDatasets(string $testsPath): void
{ {
assert($testsPath !== ''); assert(strlen($testsPath) > 0);
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php'); $files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');
+9 -3
View File
@@ -12,14 +12,20 @@ use Symfony\Component\Console\Output\OutputInterface;
/** /**
* @internal * @internal
*/ */
final readonly class BootKernelDump implements Bootstrapper final class BootKernelDump implements Bootstrapper
{ {
/**
* Creates a new Boot Kernel Dump instance.
*/
public function __construct( public function __construct(
private OutputInterface $output, private readonly OutputInterface $output,
) { ) {
// // ...
} }
/**
* Boots the kernel dump.
*/
public function boot(): void public function boot(): void
{ {
Container::getInstance()->add(KernelDump::class, $kernelDump = new KernelDump( Container::getInstance()->add(KernelDump::class, $kernelDump = new KernelDump(
+15 -12
View File
@@ -13,21 +13,24 @@ use Pest\Exceptions\ShouldNotHappen;
final class BootOverrides implements Bootstrapper final class BootOverrides implements Bootstrapper
{ {
/** /**
* @var array<int, string> * The list of files to be overridden.
*
* @var array<string, string>
*/ */
public const array FILES = [ public const FILES = [
'ParaTest/WrapperRunner/ProgressPrinterOutput.php', 'c7b9c8a96006dea314204a8f09a8764e51ce0b9b79aadd58da52e8c328db4870' => 'Runner/Filter/NameFilterIterator.php',
'Runner/Filter/NameFilterIterator.php', 'c7c09ab7c9378710b27f761a4b2948196cbbdf2a73e4389bcdca1e7c94fa9c21' => 'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestRunHistory/DefaultTestRunHistory.php', 'bc8718c89264f65800beabc23e51c6d3bcff87dfc764a12179ef5dbfde272c8b' => 'Runner/TestSuiteLoader.php',
'Runner/TestSuiteLoader.php', 'f41e48d6cb546772a7de4f8e66b6b7ce894a5318d063eb52e354d206e96c701c' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'Runner/TestSuiteSorter.php', 'cb7519f2d82893640b694492cf7ec9528da80773cc1d259634181b5d393528b5' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php', '2f06e4b1a9f3a24145bfc7ea25df4f124117f940a2cde30a04d04d5678006bff' => 'TextUI/TestSuiteFilterProcessor.php',
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php', 'ef64a657ed9c0067791483784944107827bf227c7e3200f212b6751876b99e25' => 'Event/Value/ThrowableBuilder.php',
'TextUI/TestSuiteFilterProcessor.php', 'c78f96e34b98ed01dd8106539d59b8aa8d67f733274118b827c01c5c4111c033' => 'Logging/JUnit/JunitXmlLogger.php',
'Event/Value/ThrowableBuilder.php',
'Logging/JUnit/JunitXmlLogger.php',
]; ];
/**
* Boots the list of files to be overridden.
*/
public function boot(): void public function boot(): void
{ {
foreach (self::FILES as $file) { foreach (self::FILES as $file) {
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use PHPUnit\TextUI\Configuration\Builder;
/**
* @internal
*/
final class BootPhpUnitConfiguration implements Bootstrapper
{
public function boot(): void
{
(new Builder)->build(['pest']);
}
}
+11 -20
View File
@@ -13,39 +13,30 @@ use PHPUnit\Event\Subscriber;
/** /**
* @internal * @internal
*/ */
final readonly class BootSubscribers implements Bootstrapper final class BootSubscribers implements Bootstrapper
{ {
/** /**
* The list of Subscribers.
*
* @var array<int, class-string<Subscriber>> * @var array<int, class-string<Subscriber>>
*/ */
private const array SUBSCRIBERS = [ private const SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsAvailable::class, Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
Subscribers\EnsureTiaStarts::class,
Subscribers\EnsureTiaEnds::class,
Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
Subscribers\EnsureTiaResultIsRecordedOnNoticeTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnPhpNoticeTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnDeprecationTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnPhpDeprecationTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnWarningTriggered::class,
Subscribers\EnsureTiaResultIsRecordedOnPhpWarningTriggered::class,
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
]; ];
/**
* Creates a new instance of the Boot Subscribers.
*/
public function __construct( public function __construct(
private Container $container, private readonly Container $container,
) {} ) {}
/**
* Boots the list of Subscribers.
*/
public function boot(): void public function boot(): void
{ {
foreach (self::SUBSCRIBERS as $subscriber) { foreach (self::SUBSCRIBERS as $subscriber) {
+9 -3
View File
@@ -11,14 +11,20 @@ use Symfony\Component\Console\Output\OutputInterface;
/** /**
* @internal * @internal
*/ */
final readonly class BootView implements Bootstrapper final class BootView implements Bootstrapper
{ {
/**
* Creates a new instance of the Boot View.
*/
public function __construct( public function __construct(
private OutputInterface $output private readonly OutputInterface $output
) { ) {
// // ..
} }
/**
* Boots the view renderer.
*/
public function boot(): void public function boot(): void
{ {
View::renderUsing($this->output); View::renderUsing($this->output);
-88
View File
@@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Collision;
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
use Pest\Configuration\Project;
use Symfony\Component\Console\Output\OutputInterface;
use function Termwind\render;
use function Termwind\renderUsing;
/**
* @internal
*/
final class Events
{
private static ?OutputInterface $output = null;
public static function setOutput(OutputInterface $output): void
{
self::$output = $output;
}
public static function beforeTestMethodDescription(TestResult $result, string $description): string
{
if (($context = $result->context) === []) {
return $description;
}
renderUsing(self::$output);
[
'assignees' => $assignees,
'issues' => $issues,
'prs' => $prs,
] = $context;
if (($link = Project::getInstance()->issues) !== '') {
$issuesDescription = array_map(fn (int $issue): string => sprintf('<a href="%s">#%s</a>', sprintf($link, $issue), $issue), $issues);
}
if (($link = Project::getInstance()->prs) !== '') {
$prsDescription = array_map(fn (int $pr): string => sprintf('<a href="%s">#%s</a>', sprintf($link, $pr), $pr), $prs);
}
if (($link = Project::getInstance()->assignees) !== '' && count($assignees) > 0) {
$assigneesDescription = array_map(fn (string $assignee): string => sprintf(
'<a href="%s">@%s</a>',
sprintf($link, $assignee),
$assignee,
), $assignees);
}
if (count($assignees) > 0 || count($issues) > 0 || count($prs) > 0) {
$description .= ' '.implode(', ', array_merge(
$issuesDescription ?? [],
$prsDescription ?? [],
isset($assigneesDescription) ? ['['.implode(', ', $assigneesDescription).']'] : [],
));
}
return $description;
}
public static function afterTestMethodDescription(TestResult $result): void
{
if (($context = $result->context) === []) {
return;
}
renderUsing(self::$output);
[
'notes' => $notes,
] = $context;
foreach ($notes as $note) {
render(sprintf(<<<'HTML'
<div class="ml-2">
<span class="text-gray"> // %s</span>
</div>
HTML, $note,
));
}
}
}
+2
View File
@@ -14,6 +14,8 @@ trait Expectable
/** /**
* @template TValue * @template TValue
* *
* Creates a new Expectation.
*
* @param TValue $value * @param TValue $value
* @return Expectation<TValue> * @return Expectation<TValue>
*/ */
+6 -3
View File
@@ -8,24 +8,27 @@ use Closure;
/** /**
* @internal * @internal
*
* @template T of object
*/ */
trait Extendable trait Extendable
{ {
/** /**
* The list of extends.
*
* @var array<string, Closure> * @var array<string, Closure>
*/ */
private static array $extends = []; private static array $extends = [];
/** /**
* @param-closure-this T $extend * Register a new extend.
*/ */
public function extend(string $name, Closure $extend): void public function extend(string $name, Closure $extend): void
{ {
static::$extends[$name] = $extend; static::$extends[$name] = $extend;
} }
/**
* Checks if given extend name is registered.
*/
public static function hasExtend(string $name): bool public static function hasExtend(string $name): bool
{ {
return array_key_exists($name, static::$extends); return array_key_exists($name, static::$extends);
+12
View File
@@ -9,21 +9,33 @@ namespace Pest\Concerns\Logging;
*/ */
trait WritesToConsole trait WritesToConsole
{ {
/**
* Writes the given success message to the console.
*/
private function writeSuccess(string $message): void private function writeSuccess(string $message): void
{ {
$this->writePestTestOutput($message, 'fg-green, bold', '✓'); $this->writePestTestOutput($message, 'fg-green, bold', '✓');
} }
/**
* Writes the given error message to the console.
*/
private function writeError(string $message): void private function writeError(string $message): void
{ {
$this->writePestTestOutput($message, 'fg-red, bold', ''); $this->writePestTestOutput($message, 'fg-red, bold', '');
} }
/**
* Writes the given warning message to the console.
*/
private function writeWarning(string $message): void private function writeWarning(string $message): void
{ {
$this->writePestTestOutput($message, 'fg-yellow, bold', '-'); $this->writePestTestOutput($message, 'fg-yellow, bold', '-');
} }
/**
* Writes the give message to the console.
*/
private function writePestTestOutput(string $message, string $color, string $symbol): void private function writePestTestOutput(string $message, string $color, string $symbol): void
{ {
$this->writeWithColor($color, "$symbol ", false); $this->writeWithColor($color, "$symbol ", false);
+12 -1
View File
@@ -12,21 +12,30 @@ use Closure;
trait Pipeable trait Pipeable
{ {
/** /**
* The list of pipes.
*
* @var array<string, array<Closure(Closure, mixed ...$arguments): void>> * @var array<string, array<Closure(Closure, mixed ...$arguments): void>>
*/ */
private static array $pipes = []; private static array $pipes = [];
/** /**
* The list of interceptors.
*
* @var array<string, array<Closure(Closure, mixed ...$arguments): void>> * @var array<string, array<Closure(Closure, mixed ...$arguments): void>>
*/ */
private static array $interceptors = []; private static array $interceptors = [];
/**
* Register a pipe to be applied before an expectation is checked.
*/
public function pipe(string $name, Closure $pipe): void public function pipe(string $name, Closure $pipe): void
{ {
self::$pipes[$name][] = $pipe; self::$pipes[$name][] = $pipe;
} }
/** /**
* Register an interceptor that should replace an existing expectation.
*
* @param string|Closure(mixed $value, mixed ...$arguments):bool $filter * @param string|Closure(mixed $value, mixed ...$arguments):bool $filter
*/ */
public function intercept(string $name, string|Closure $filter, Closure $handler): void public function intercept(string $name, string|Closure $filter, Closure $handler): void
@@ -51,10 +60,12 @@ trait Pipeable
} }
/** /**
* Get the list of pipes by the given name.
*
* @return array<int, Closure> * @return array<int, Closure>
*/ */
private function pipes(string $name, object $context, string $scope): array private function pipes(string $name, object $context, string $scope): array
{ {
return array_map(fn (Closure $pipe): Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []); return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
} }
} }
+2
View File
@@ -11,6 +11,8 @@ trait Retrievable
{ {
/** /**
* @template TRetrievableValue * @template TRetrievableValue
*
* Safely retrieve the value at the given key from an object or array.
* @template TRetrievableValue * @template TRetrievableValue
* *
* @param array<string, TRetrievableValue>|object $value * @param array<string, TRetrievableValue>|object $value
+117 -289
View File
@@ -5,29 +5,14 @@ declare(strict_types=1);
namespace Pest\Concerns; namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgsCountMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Enums\ReplayType;
use Pest\Plugins\Tia\Recorder;
use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest;
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 Throwable; use Throwable;
/** /**
@@ -37,65 +22,83 @@ use Throwable;
*/ */
trait Testable trait Testable
{ {
/**
* The test's description.
*/
private string $__description; private string $__description;
/**
* The test's latest description.
*/
private static string $__latestDescription; private static string $__latestDescription;
private static array $__latestAssignees = []; /**
* The test's describing, if any.
private static array $__latestNotes = []; */
public ?string $__describing = null;
/** /**
* @var array<int, int> * The test's test closure.
*/ */
private static array $__latestIssues = [];
/**
* @var array<int, int>
*/
private static array $__latestPrs = [];
/**
* @var array<int, string>
*/
public array $__describing = [];
public bool $__ran = false;
private ReplayType $__replay = ReplayType::None;
private int $__replayAssertions = 0;
private Closure $__test; private Closure $__test;
/**
* The test's before each closure.
*/
private ?Closure $__beforeEach = null; private ?Closure $__beforeEach = null;
/**
* The test's after each closure.
*/
private ?Closure $__afterEach = null; private ?Closure $__afterEach = null;
/**
* The test's before all closure.
*/
private static ?Closure $__beforeAll = null; private static ?Closure $__beforeAll = null;
/**
* The test's after all closure.
*/
private static ?Closure $__afterAll = null; private static ?Closure $__afterAll = null;
/**
* The list of snapshot changes, if any.
*/
private array $__snapshotChanges = []; private array $__snapshotChanges = [];
/**
* Resets the test case static properties.
*/
public static function flush(): void public static function flush(): void
{ {
self::$__beforeAll = null; self::$__beforeAll = null;
self::$__afterAll = null; self::$__afterAll = null;
} }
public function note(array|string $note): self /**
* Creates a new Test Case instance.
*/
public function __construct(string $name)
{ {
$note = is_array($note) ? $note : [$note]; parent::__construct($name);
self::$__latestNotes = array_merge(self::$__latestNotes, $note); $test = TestSuite::getInstance()->tests->get(self::$__filename);
return $this; if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
$this->__describing = $method->describing;
$this->__test = $method->getClosure($this);
}
} }
/**
* Adds a new "setUpBeforeClass" to the Test Case.
*/
public function __addBeforeAll(?Closure $hook): void public function __addBeforeAll(?Closure $hook): void
{ {
if (! $hook instanceof Closure) { if (! $hook instanceof \Closure) {
return; return;
} }
@@ -104,9 +107,12 @@ trait Testable
: $hook; : $hook;
} }
/**
* Adds a new "tearDownAfterClass" to the Test Case.
*/
public function __addAfterAll(?Closure $hook): void public function __addAfterAll(?Closure $hook): void
{ {
if (! $hook instanceof Closure) { if (! $hook instanceof \Closure) {
return; return;
} }
@@ -115,19 +121,28 @@ trait Testable
: $hook; : $hook;
} }
/**
* Adds a new "setUp" to the Test Case.
*/
public function __addBeforeEach(?Closure $hook): void public function __addBeforeEach(?Closure $hook): void
{ {
$this->__addHook('__beforeEach', $hook); $this->__addHook('__beforeEach', $hook);
} }
/**
* Adds a new "tearDown" to the Test Case.
*/
public function __addAfterEach(?Closure $hook): void public function __addAfterEach(?Closure $hook): void
{ {
$this->__addHook('__afterEach', $hook); $this->__addHook('__afterEach', $hook);
} }
/**
* Adds a new "hook" to the Test Case.
*/
private function __addHook(string $property, ?Closure $hook): void private function __addHook(string $property, ?Closure $hook): void
{ {
if (! $hook instanceof Closure) { if (! $hook instanceof \Closure) {
return; return;
} }
@@ -136,6 +151,9 @@ trait Testable
: $hook; : $hook;
} }
/**
* This method is called before the first test of this Test Case is run.
*/
public static function setUpBeforeClass(): void public static function setUpBeforeClass(): void
{ {
parent::setUpBeforeClass(); parent::setUpBeforeClass();
@@ -146,13 +164,12 @@ trait Testable
$beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll); $beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll);
} }
try { call_user_func(Closure::bind($beforeAll, null, self::class));
call_user_func(Closure::bind($beforeAll, null, self::class));
} catch (Throwable $e) {
Panic::with($e);
}
} }
/**
* This method is called after the last test of this Test Case is run.
*/
public static function tearDownAfterClass(): void public static function tearDownAfterClass(): void
{ {
$afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename); $afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename);
@@ -166,20 +183,17 @@ trait Testable
parent::tearDownAfterClass(); parent::tearDownAfterClass();
} }
protected function setUp(...$arguments): void /**
* Gets executed before the Test Case.
*/
protected function setUp(): void
{ {
TestSuite::getInstance()->test = $this; TestSuite::getInstance()->test = $this;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description; $description = $this->dataName() ? $method->description.' with '.$this->dataName() : $method->description;
if ($this->dataName()) { $description = htmlspecialchars(html_entity_decode($description), ENT_NOQUOTES);
$description = str_contains((string) $description, ':dataset')
? str_replace(':dataset', str_replace('dataset ', '', $this->dataName()), (string) $description)
: $description.' with '.$this->dataName();
}
$description = htmlspecialchars(html_entity_decode((string) $description), ENT_NOQUOTES);
if ($method->repetitions > 1) { if ($method->repetitions > 1) {
$matches = []; $matches = [];
@@ -197,89 +211,23 @@ trait Testable
} }
$this->__description = self::$__latestDescription = $description; $this->__description = self::$__latestDescription = $description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
$replay = ReplayType::fromStatus($status);
if ($replay !== ReplayType::None) {
assert($status !== null);
$this->__replay = $replay;
match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
};
return;
}
$recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder);
if ($recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
parent::setUp(); parent::setUp();
Collectors::armAll($recorder);
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) { if ($this->__beforeEach instanceof Closure) {
$beforeEach = ChainableClosure::bound($this->__beforeEach, $beforeEach); $beforeEach = ChainableClosure::bound($this->__beforeEach, $beforeEach);
} }
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, func_get_args());
} }
private function __beginReplay(ReplayType $replay, Tia $tia): void /**
* Gets executed after the Test Case.
*/
protected function tearDown(): void
{ {
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
$this->__ran = true;
}
public function __initializeTestCase(): void
{
if (isset($this->__test)) {
return;
}
$name = $this->name();
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure();
$method->setUp($this);
}
}
protected function tearDown(...$arguments): void
{
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) { if ($this->__afterEach instanceof Closure) {
@@ -292,154 +240,45 @@ trait Testable
parent::tearDown(); parent::tearDown();
TestSuite::getInstance()->test = null; TestSuite::getInstance()->test = null;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$method->tearDown($this);
} }
} }
/** /**
* Executes the Test Case current test.
*
* @throws Throwable * @throws Throwable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
$this->expectNotToPerformAssertions();
}
$this->addToAssertionCount($this->__replayAssertions);
return null;
}
$arguments = $this->__resolveTestArguments($args); $arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments); $this->__ensureDatasetArgumentNumberMatches($arguments);
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); return $this->__callClosure($closure, $arguments);
if ($method->flakyTries === null) {
return $this->__callClosure($closure, $arguments);
}
$lastException = null;
$initialProperties = get_object_vars($this);
for ($attempt = 1; $attempt <= $method->flakyTries; $attempt++) {
try {
return $this->__callClosure($closure, $arguments);
} catch (Throwable $e) {
if ($e instanceof SkippedTest
|| $e instanceof IncompleteTest
|| $this->__isExpectedException($e)) {
throw $e;
}
$lastException = $e;
if ($attempt < $method->flakyTries) {
if ($this->__snapshotChanges !== []) {
throw $e;
}
$this->tearDown();
TestSuite::getInstance()->snapshots->forget();
Closure::bind(fn (): array => $this->mockObjects = [], $this, TestCase::class)();
foreach (array_keys(array_diff_key(get_object_vars($this), $initialProperties)) as $property) {
unset($this->{$property});
}
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
if ($outputBuffer->hasExpectation()) {
ob_clean();
Closure::bind(function (): void {
$this->expectedString = null;
$this->expectedRegularExpression = null;
}, $outputBuffer, OutputBuffer::class)();
}
$this->setUp();
}
}
}
throw $lastException;
}
private function __isExpectedException(Throwable $e): bool
{
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
$expectedClass = $read('expectedException');
if ($expectedClass !== null) {
return $e instanceof $expectedClass;
}
$expectedMessage = $read('expectedMessage');
if ($expectedMessage !== null) {
return str_contains($e->getMessage(), (string) $expectedMessage);
}
$expectedMessageRegex = $read('expectedMessageRegularExpression');
if ($expectedMessageRegex !== null) {
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
}
$expectedCode = $read('expectedCode');
if ($expectedCode !== null) {
return $e->getCode() === $expectedCode;
}
return false;
} }
/** /**
* Resolve the passed arguments. Any Closures will be bound to the testcase and resolved.
*
* @throws Throwable * @throws Throwable
*/ */
private function __resolveTestArguments(array $arguments): array private function __resolveTestArguments(array $arguments): array
{ {
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
$testParameterTypes = array_values($testParameterTypesByName);
if ($method->repetitions > 1) { if ($method->repetitions > 1) {
$firstArgument = array_shift($arguments); array_shift($arguments);
if (array_is_list($arguments)) {
$arguments[] = $firstArgument;
} else {
$testParameterNames = array_keys($testParameterTypesByName);
$iterationParameterName = $testParameterNames[count($arguments)] ?? null;
if ($iterationParameterName !== null) {
$arguments[$iterationParameterName] = $firstArgument;
}
}
} }
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest));
if (count($arguments) !== 1) { if (count($arguments) !== 1) {
foreach ($arguments as $argumentIndex => $argumentValue) { foreach ($arguments as $argumentIndex => $argumentValue) {
if (! $argumentValue instanceof Closure) { if (! $argumentValue instanceof Closure) {
continue; continue;
} }
$parameterType = is_string($argumentIndex) if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) {
? $testParameterTypesByName[$argumentIndex]
: $testParameterTypes[$argumentIndex];
if (in_array($parameterType, [Closure::class, 'callable', 'mixed'])) {
continue; continue;
} }
@@ -449,7 +288,7 @@ trait Testable
return $arguments; return $arguments;
} }
if (! isset($arguments[0]) || ! $arguments[0] instanceof Closure) { if (! $arguments[0] instanceof Closure) {
return $arguments; return $arguments;
} }
@@ -465,14 +304,16 @@ trait Testable
return [$boundDatasetResult]; return [$boundDatasetResult];
} }
return $boundDatasetResult; return array_values($boundDatasetResult);
} }
/** /**
* Ensures dataset items count matches underlying test case required parameters
*
* @throws ReflectionException * @throws ReflectionException
* @throws DatasetArgumentsMismatch * @throws DatasetArgsCountMismatch
*/ */
private function __ensureDatasetArgumentNameAndNumberMatches(array $arguments): void private function __ensureDatasetArgumentNumberMatches(array $arguments): void
{ {
if ($arguments === []) { if ($arguments === []) {
return; return;
@@ -483,21 +324,11 @@ trait Testable
$requiredParametersCount = $testReflection->getNumberOfRequiredParameters(); $requiredParametersCount = $testReflection->getNumberOfRequiredParameters();
$suppliedParametersCount = count($arguments); $suppliedParametersCount = count($arguments);
$datasetParameterNames = array_keys($arguments); if ($suppliedParametersCount >= $requiredParametersCount) {
$testParameterNames = array_map(
fn (ReflectionParameter $reflectionParameter): string => $reflectionParameter->getName(),
array_filter($testReflection->getParameters(), fn (ReflectionParameter $reflectionParameter): bool => ! $reflectionParameter->isOptional()),
);
if (array_diff($testParameterNames, $datasetParameterNames) === []) {
return; return;
} }
if (isset($testParameterNames[0]) && $suppliedParametersCount >= $requiredParametersCount) { throw new DatasetArgsCountMismatch($requiredParametersCount, $suppliedParametersCount);
return;
}
throw new DatasetArgumentsMismatch($requiredParametersCount, $suppliedParametersCount);
} }
/** /**
@@ -508,48 +339,45 @@ trait Testable
return ExceptionTrace::ensure(fn (): mixed => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments)); return ExceptionTrace::ensure(fn (): mixed => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments));
} }
public function preset(): Preset /** @postCondition */
{
return new Preset;
}
#[PostCondition]
protected function __MarkTestIncompleteIfSnapshotHaveChanged(): void protected function __MarkTestIncompleteIfSnapshotHaveChanged(): void
{ {
if (count($this->__snapshotChanges) === 0) { if (count($this->__snapshotChanges) === 0) {
return; return;
} }
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges)); if (count($this->__snapshotChanges) === 1) {
$this->markTestIncomplete($this->__snapshotChanges[0]);
return;
}
$messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges));
$this->markTestIncomplete($messages);
} }
/**
* The printable test case name.
*/
public static function getPrintableTestCaseName(): string public static function getPrintableTestCaseName(): string
{ {
return preg_replace('/P\\\/', '', self::class, 1); return preg_replace('/P\\\/', '', self::class, 1);
} }
/**
* The printable test case method name.
*/
public function getPrintableTestCaseMethodName(): string public function getPrintableTestCaseMethodName(): string
{ {
return $this->__description; return $this->__description;
} }
/**
* The latest printable test case method name.
*/
public static function getLatestPrintableTestCaseMethodName(): string public static function getLatestPrintableTestCaseMethodName(): string
{ {
return self::$__latestDescription ?? ''; return self::$__latestDescription;
}
public static function getPrintableContext(): array
{
return [
'assignees' => self::$__latestAssignees,
'issues' => self::$__latestIssues,
'prs' => self::$__latestPrs,
'notes' => self::$__latestNotes,
];
}
public function shell(): void
{
Shell::open();
} }
} }
-100
View File
@@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest;
use Pest\PendingCalls\BeforeEachCall;
use Pest\PendingCalls\UsesCall;
/**
* @internal
*
* @mixin UsesCall
*/
final readonly class Configuration
{
private string $filename;
public function __construct(
string $filename,
) {
$this->filename = str_ends_with($filename, DIRECTORY_SEPARATOR.'Pest.php') ? dirname($filename) : $filename;
}
public function in(string ...$targets): UsesCall
{
return new UsesCall($this->filename, [])->in(...$targets);
}
public function extend(string ...$classAndTraits): UsesCall
{
return new UsesCall(
$this->filename,
array_values($classAndTraits)
);
}
public function extends(string ...$classAndTraits): UsesCall
{
return $this->extend(...$classAndTraits);
}
public function group(string ...$groups): UsesCall
{
return new UsesCall($this->filename, [])->group(...$groups);
}
public function only(): void
{
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
}
public function use(string ...$classAndTraits): UsesCall
{
return $this->extend(...$classAndTraits);
}
public function uses(string ...$classAndTraits): UsesCall
{
return $this->extends(...$classAndTraits);
}
public function printer(): Configuration\Printer
{
return new Configuration\Printer;
}
public function presets(): Configuration\Presets
{
return new Configuration\Presets;
}
public function project(): Configuration\Project
{
return Configuration\Project::getInstance();
}
public function browser(): Browser\Configuration
{
return new Browser\Configuration;
}
public function evals(): Evals\Configuration // @phpstan-ignore-line
{
return new Evals\Configuration; // @phpstan-ignore-line
}
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/**
* @param array<array-key, mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
return $this->uses()->$name(...$arguments); // @phpstan-ignore-line
}
}
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Configuration;
use Closure;
use Pest\Preset;
final class Presets
{
public function custom(string $name, Closure $execute): void
{
Preset::custom($name, $execute);
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Configuration;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
/**
* @internal
*/
final readonly class Printer
{
public function compact(): self
{
DefaultPrinter::compact(true);
return $this;
}
}
-84
View File
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Configuration;
/**
* @internal
*/
final class Project
{
/**
* @internal
*/
public string $assignees = '';
/**
* @internal
*/
public string $issues = '';
/**
* @internal
*/
public string $prs = '';
private static ?self $instance = null;
public static function getInstance(): self
{
return self::$instance ??= new self;
}
public function github(string $project): self
{
$this->issues = "https://github.com/{$project}/issues/%s";
$this->prs = "https://github.com/{$project}/pull/%s";
$this->assignees = 'https://github.com/%s';
return $this;
}
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
{
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
$this->assignees = "https://{$hostname}/%s";
return $this;
}
public function bitbucket(string $project): self
{
$this->issues = "https://bitbucket.org/{$project}/issues/%s";
$this->prs = "https://bitbucket.org/{$project}/pull-requests/%s";
$this->assignees = 'https://bitbucket.org/%s';
return $this;
}
public function jira(string $namespace, string $project): self
{
$this->issues = "https://{$namespace}.atlassian.net/browse/{$project}-%s";
$this->assignees = "https://{$namespace}.atlassian.net/secure/ViewProfile.jspa?name=%s";
return $this;
}
public function custom(string $issues, string $prs, string $assignees): self
{
$this->issues = $issues;
$this->prs = $prs;
$this->assignees = $assignees;
return $this;
}
}
+12 -4
View File
@@ -9,12 +9,14 @@ use Symfony\Component\Console\Output\OutputInterface;
/** /**
* @internal * @internal
*/ */
final readonly class Help final class Help
{ {
/** /**
* The Command messages.
*
* @var array<int, string> * @var array<int, string>
*/ */
private const array HELP_MESSAGES = [ private const HELP_MESSAGES = [
'<comment>Pest Options:</comment>', '<comment>Pest Options:</comment>',
' <info>--init</info> Initialise a standard Pest configuration', ' <info>--init</info> Initialise a standard Pest configuration',
' <info>--coverage</info> Enable coverage and output to standard output', ' <info>--coverage</info> Enable coverage and output to standard output',
@@ -22,11 +24,17 @@ final readonly class Help
' <info>--group=<fg=cyan><name></></info> Only runs tests from the specified group(s)', ' <info>--group=<fg=cyan><name></></info> Only runs tests from the specified group(s)',
]; ];
public function __construct(private OutputInterface $output) /**
* Creates a new Console Command instance.
*/
public function __construct(private readonly OutputInterface $output)
{ {
// // ..
} }
/**
* Executes the Console Command.
*/
public function __invoke(): void public function __invoke(): void
{ {
foreach (self::HELP_MESSAGES as $message) { foreach (self::HELP_MESSAGES as $message) {
+18 -14
View File
@@ -15,29 +15,33 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
/** /**
* @internal * @internal
*/ */
final readonly class Thanks final class Thanks
{ {
/** /**
* The support options.
*
* @var array<string, string> * @var array<string, string>
*/ */
private const array FUNDING_MESSAGES = [ private const FUNDING_MESSAGES = [
'Star' => 'https://github.com/pestphp/pest', 'Star' => 'https://github.com/pestphp/pest',
'YouTube' => 'https://youtube.com/@nunomaduro', 'News' => 'https://twitter.com/pestphp',
'TikTok' => 'https://tiktok.com/@enunomaduro', 'Videos' => 'https://youtube.com/@nunomaduro',
'Twitch' => 'https://twitch.tv/nunomaduro',
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
'Instagram' => 'https://instagram.com/enunomaduro',
'X' => 'https://x.com/enunomaduro',
'Sponsor' => 'https://github.com/sponsors/nunomaduro', 'Sponsor' => 'https://github.com/sponsors/nunomaduro',
]; ];
/**
* Creates a new Console Command instance.
*/
public function __construct( public function __construct(
private InputInterface $input, private readonly InputInterface $input,
private OutputInterface $output private readonly OutputInterface $output
) { ) {
// // ..
} }
/**
* Executes the Console Command.
*/
public function __invoke(): void public function __invoke(): void
{ {
$bootstrapper = new BootView($this->output); $bootstrapper = new BootView($this->output);
@@ -68,13 +72,13 @@ final readonly class Thanks
} }
if ($wantsToSupport === true) { if ($wantsToSupport === true) {
if (PHP_OS_FAMILY === 'Darwin') { if (PHP_OS_FAMILY == 'Darwin') {
exec('open https://github.com/pestphp/pest'); exec('open https://github.com/pestphp/pest');
} }
if (PHP_OS_FAMILY === 'Windows') { if (PHP_OS_FAMILY == 'Windows') {
exec('start https://github.com/pestphp/pest'); exec('start https://github.com/pestphp/pest');
} }
if (PHP_OS_FAMILY === 'Linux') { if (PHP_OS_FAMILY == 'Linux') {
exec('xdg-open https://github.com/pestphp/pest'); exec('xdg-open https://github.com/pestphp/pest');
} }
} }
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
interface AddsAnnotations
{
/**
* Adds annotations to the given test case method.
*
* @param array<int, string> $annotations
* @return array<int, string>
*/
public function __invoke(TestCaseMethodFactory $method, array $annotations): array;
}
-10
View File
@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface ArchPreset {}
+3
View File
@@ -9,5 +9,8 @@ namespace Pest\Contracts;
*/ */
interface Bootstrapper interface Bootstrapper
{ {
/**
* Boots the bootstrapper.
*/
public function boot(): void; public function boot(): void;
} }
+1 -1
View File
@@ -11,5 +11,5 @@ use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName as
*/ */
interface HasPrintableTestCaseName extends BaseHasPrintableTestCaseName interface HasPrintableTestCaseName extends BaseHasPrintableTestCaseName
{ {
// // ..
} }
+6
View File
@@ -11,7 +11,13 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
interface Panicable interface Panicable
{ {
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void; public function render(OutputInterface $output): void;
/**
* The exit code to be used.
*/
public function exitCode(): int; public function exitCode(): int;
} }
+3
View File
@@ -9,5 +9,8 @@ namespace Pest\Contracts\Plugins;
*/ */
interface AddsOutput interface AddsOutput
{ {
/**
* Adds output after the Test Suite execution.
*/
public function addOutput(int $exitCode): int; public function addOutput(int $exitCode): int;
} }
+3
View File
@@ -9,5 +9,8 @@ namespace Pest\Contracts\Plugins;
*/ */
interface Bootable interface Bootable
{ {
/**
* Boots the plugin.
*/
public function boot(): void; public function boot(): void;
} }
@@ -10,6 +10,8 @@ namespace Pest\Contracts\Plugins;
interface HandlesArguments interface HandlesArguments
{ {
/** /**
* Adds arguments before the Test Suite execution.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
* @return array<int, string> * @return array<int, string>
*/ */
@@ -10,6 +10,8 @@ namespace Pest\Contracts\Plugins;
interface HandlesOriginalArguments interface HandlesOriginalArguments
{ {
/** /**
* Adds original arguments before the Test Suite execution.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
*/ */
public function handleOriginalArguments(array $arguments): void; public function handleOriginalArguments(array $arguments): void;
+3
View File
@@ -9,5 +9,8 @@ namespace Pest\Contracts\Plugins;
*/ */
interface Terminable interface Terminable
{ {
/**
* Terminates the plugin.
*/
public function terminate(): void; public function terminate(): void;
} }
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void;
}
+3
View File
@@ -6,5 +6,8 @@ namespace Pest\Contracts;
interface TestCaseFilter interface TestCaseFilter
{ {
/**
* Whether the test case is accepted.
*/
public function accept(string $testCaseFilename): bool; public function accept(string $testCaseFilename): bool;
} }
+3
View File
@@ -8,5 +8,8 @@ use Pest\Factories\TestCaseMethodFactory;
interface TestCaseMethodFilter interface TestCaseMethodFilter
{ {
/**
* Whether the test case method is accepted.
*/
public function accept(TestCaseMethodFactory $factory): bool; public function accept(TestCaseMethodFactory $factory): bool;
} }
-31
View File
@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Evaluators;
use Pest\Factories\Attribute;
/**
* @internal
*/
final class Attributes
{
/**
* @param iterable<int, Attribute> $attributes
*/
public static function code(iterable $attributes): string
{
return implode(PHP_EOL, array_map(function (Attribute $attribute): string {
$name = $attribute->name;
if ($attribute->arguments === []) {
return " #[\\{$name}]";
}
$arguments = array_map(fn (string $argument): string => var_export($argument, true), iterator_to_array($attribute->arguments));
return sprintf(' #[\\%s(%s)]', $name, implode(', ', $arguments));
}, iterator_to_array($attributes)));
}
}
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class AfterAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class AfterAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The [afterAll] hook is already defined in [%s]. Each test file may only define it once.', $filename)); parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
} }
} }
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class AfterAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class AfterAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The [afterAll] hook may not be used inside a [describe] block. Please move it to the top level of [%s].', $filename)); parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
} }
} }
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterBeforeTestFunction extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
public function __construct(string $filename)
{
parent::__construct(sprintf('The [after] hook may only be chained onto [beforeEach] inside a [describe] block. Please move it inside one in [%s].', $filename));
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Pest\Support\View;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(
private readonly string $headline,
private readonly string $hint,
private readonly bool $hasAnchor = false,
) {
parent::__construct($headline);
}
public function render(OutputInterface $output): void
{
View::renderUsing($output);
if (! $this->hasAnchor) {
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
return;
}
$this->renderChild($output, $this->headline);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
}
public function exitCode(): int
{
return 1;
}
private function renderChild(OutputInterface $output, string $text): void
{
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
}
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class BeforeAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class BeforeAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The [beforeAll] hook is already defined in [%s]. Each test file may only define it once.', $filename)); parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
} }
} }
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class BeforeAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class BeforeAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The [beforeAll] hook may not be used inside a [describe] block. Please move it to the top level of [%s].', $filename)); parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
} }
} }
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class DatasetAlreadyExists extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class DatasetAlreadyExists extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $name, string $scope) public function __construct(string $name, string $scope)
{ {
parent::__construct(sprintf('A dataset named [%s] is already registered in [%s]. Please choose a different name.', $name, $scope)); parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
} }
} }
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
final class DatasetArgsCountMismatch extends Exception
{
public function __construct(int $requiredCount, int $suppliedCount)
{
parent::__construct(sprintf('Test expects %d arguments but dataset only provides %d', $requiredCount, $suppliedCount));
}
}
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
final class DatasetArgumentsMismatch extends Exception
{
public function __construct(int $requiredCount, int $suppliedCount)
{
if ($requiredCount <= $suppliedCount) {
parent::__construct('The test arguments do not match the dataset keys. Please make sure each argument is named after a key in the dataset.');
} else {
parent::__construct(sprintf('The test expects [%d] argument(s), but the dataset only provides [%d].', $requiredCount, $suppliedCount));
}
}
}
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class DatasetDoesNotExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class DatasetDoesNotExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $name) public function __construct(string $name)
{ {
parent::__construct(sprintf("A dataset named [%s] does not exist. You may create one 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));
} }
} }
+4 -2
View File
@@ -15,16 +15,18 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
final class DatasetMissing extends BadFunctionCallException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class DatasetMissing extends BadFunctionCallException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/** /**
* Creates a new Exception instance.
*
* @param array<string, string> $arguments * @param array<string, string> $arguments
*/ */
public function __construct(string $file, string $name, array $arguments) public function __construct(string $file, string $name, array $arguments)
{ {
parent::__construct(sprintf( parent::__construct(sprintf(
'The test [%s] in [%s] expects [%d] argument(s) ([%s]), but no dataset was provided. Please chain [with()] onto the test to supply one.', "A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s",
$name, $name,
$file,
count($arguments), count($arguments),
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)), implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),
$file,
)); ));
} }
} }
-19
View File
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use RuntimeException;
use Throwable;
/**
* @internal
*/
final class DatasetProviderError extends RuntimeException
{
public function __construct(Throwable $previous)
{
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
}
}
+4 -1
View File
@@ -11,8 +11,11 @@ use Exception;
*/ */
final class ExpectationNotFound extends Exception final class ExpectationNotFound extends Exception
{ {
/**
* Creates a new ExpectationNotFound instance from the given name.
*/
public static function fromName(string $name): ExpectationNotFound public static function fromName(string $name): ExpectationNotFound
{ {
return new self("The expectation [$name] does not exist. You may register it using [expect()->extend()]."); return new self("Expectation [$name] does not exist.");
} }
} }
+4 -1
View File
@@ -14,8 +14,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class FileOrFolderNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class FileOrFolderNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The file or folder [%s] could not be found. Please check the path and try again.', $filename)); parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
} }
} }
@@ -14,6 +14,9 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
*/ */
final class InvalidArgumentException extends BaseInvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class InvalidArgumentException extends BaseInvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/**
* Creates a new Exception instance.
*/
public function __construct(string $message) public function __construct(string $message)
{ {
parent::__construct($message, 1); parent::__construct($message, 1);
+1 -1
View File
@@ -21,6 +21,6 @@ final class InvalidExpectation extends LogicException implements ExceptionInterf
*/ */
public static function fromMethods(array $methods): never public static function fromMethods(array $methods): never
{ {
throw new self(sprintf('The expectation [%s] does not exist. Please check the spelling, or register it using [expect()->extend()].', implode('->', $methods))); throw new self(sprintf('Expectation [%s] is not valid.', implode('->', $methods)));
} }
} }
+1 -1
View File
@@ -16,6 +16,6 @@ final class InvalidExpectationValue extends InvalidArgumentException
*/ */
public static function expected(string $type): never public static function expected(string $type): never
{ {
throw new self(sprintf('This expectation may only be used on a value of type [%s].', $type)); throw new self(sprintf('Invalid expectation value type. Expected [%s].', $type));
} }
} }

Some files were not shown because too many files have changed in this diff Show More