Compare commits

...

25 Commits

Author SHA1 Message Date
Lazizbek Ergashev 20a5aacaca fix(tia): re-anchor after a full suite run without a coverage driver (#1866)
Co-authored-by: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com>
2026-08-25 23:45:02 +05:30
nuno maduro eac05bcee9 release: 5.1.3 2026-08-25 18:10:42 +01:00
Pushpak Chhajed b5b2ba7d7f chore: drop boost pest-testing skill (#1889)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 16:42:49 +01:00
nuno maduro 627092ecfc chore: bumps deps 2026-08-24 14:06:57 +01:00
nuno maduro 2692613f09 chore: ignore hod 2026-08-24 10:58:51 +01:00
nuno maduro e763a87b42 chore: updates snapshots 2026-08-24 10:51:08 +01:00
nuno maduro 4a65054f83 chore: style 2026-08-24 09:39:08 +01:00
nuno maduro b8c2265d66 fix: snapshot key on retry / repeat 2026-08-24 09:31:34 +01:00
Daniel Polito 39cbdcd52b Prevent memory exhaustion when generating TIA graphs with PCOV (#1875)
* fix(tia): preserve memory limit during pcov restart

* chore: update test expectations

* fix: stabilize tia replay test in ci
2026-08-24 08:41:59 +01:00
Lazizbek Ergashev aba2b79609 fix: exit code with --shard and --parallel (#1883)
* fix: exit code with --shard and --parallel

* fix: match existing argument-handling style in Shard
2026-08-24 08:41:01 +01:00
nuno maduro 1af74a215c chore: new sponsor 2026-08-18 19:42:16 +01:00
Andrew Matia cfe89d20ac Fix repeat with named dataset arguments (#1769) 2026-08-16 15:08:46 +01:00
nuno maduro 2c7cc1dc87 fix: static callable datasets 2026-08-13 18:38:27 +01:00
Lazizbek Ergashev 9b36f44b3e fix(tia): include the PHP minor version in the fingerprint (#1860) 2026-08-13 16:16:15 +01:00
Lazizbek Ergashev 2e58918201 fix(tia): treat a test skipped from a hook as finished (#1863) 2026-08-13 16:15:45 +01:00
nuno maduro a8d4770c0b chore: fix version 2026-08-12 15:37:18 +01:00
nuno maduro 208f447a10 chore: style 2026-08-12 15:29:21 +01:00
Daniel Polito 6b7ca13660 feat: allow configuring the TIA directory (#1787)
* feat: allow configuring the TIA directory

* refactor: simplify TIA directory resolution

* fix: bootstrap TIA state after configuration

* chore: apply Rector to TIA storage test

* fix: stabilize checks on pull requests

* fix: align TIA fixture branch resolution

* fix: preserve success snapshot formatting

* fix: order TIA storage snapshot

* chore: keep TIA directory changes scoped
2026-08-12 14:53:45 +01:00
nuno maduro bf7c6b3134 fix(tia): casing issues 2026-08-11 01:47:14 +01:00
Caleb White 617226ca01 fix: fail missing snapshots in CI (#1842)
Prevent snapshot assertions from silently creating missing snapshots during CI runs unless --update-snapshots is enabled.
2026-08-11 01:02:44 +01:00
nuno maduro 375b92e93b chore: updates sponsors 2026-08-10 11:25:58 +01:00
nuno maduro e0e00ca748 chore: style 2026-08-10 11:12:08 +01:00
nuno maduro d70ecb873e chore: skip --tia on --mutate 2026-08-10 11:07:46 +01:00
nuno maduro 638932ddb5 chore: updates snapshots 2026-08-10 10:22:36 +01:00
nuno maduro ac48185736 chore: upgrades to phpunit:13.3 2026-08-10 10:16:30 +01:00
74 changed files with 1674 additions and 921 deletions
+3
View File
@@ -3,12 +3,15 @@
/tests export-ignore
/scripts export-ignore
/.github export-ignore
/.hod export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
/phpstan.neon export-ignore
/phpunit.xml export-ignore
/CHANGELOG.md export-ignore
/AGENTS.md export-ignore
/CLAUDE.md export-ignore
/CONTRIBUTING.md export-ignore
/docker-compose.yml export-ignore
/Makefile export-ignore
+3
View File
@@ -14,3 +14,6 @@ coverage.xml
.vscode/
.STREAM.md
/.claude/skills/
/.agents/skills/
+7
View File
@@ -0,0 +1,7 @@
# 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
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,74 @@
---
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.
@@ -0,0 +1,5 @@
interface:
display_name: "Snapshots Update"
short_description: "Write each snapshot of this project again"
policy:
allow_implicit_invocation: false
+75
View File
@@ -0,0 +1,75 @@
# 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 -34
View File
@@ -1,34 +1 @@
# CLAUDE.md
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
## Ask before testing
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
Once the user confirms:
```bash
composer test:unit # fast, excludes the visual group
composer test:integration # visual and snapshot tests
composer test # everything CI runs, in CI's order
composer update:snapshots # only when a test was added or removed
```
## TIA scenario tests
`tests/Features/Tia/*` scaffold a throwaway git project, run a real `pest` subprocess against it, and diff the TIA graph it wrote. They exist because TIA's contract is about what a run *writes* — replay, branch keys, and the COMPLETE / RESULTS-ONLY / HARD-SUPPRESSED tiers are invisible to ordinary assertions, and every case used to be measured by hand against a playground app.
Add one whenever a change touches branch resolution, replay, filtered mode, or the write tiers. How:
- `Project::make('master')` scaffolds; `seed('master')` writes a graph and sentinels every cached result (`time=9.999`, `assertions=42`) so any rewrite shows up.
- `$project->pest('--tia', …)` runs it; `$project->delta()` compares against that snapshot. `writtenCount()` is the discriminator — `0` means "replayed", not "wrote the same values". `mutateGraph()` bends one entry; overlays in `tests/Fixtures/Tia/overlays/<name>/` supply a different `tests/Pest.php`.
- Keep expectations driver-independent: a cold recording run needs pcov/xdebug and behaves differently without one. Seed a graph instead of recording one.
Run them by file (a directory argument finds nothing) or by `--filter`:
```bash
php bin/pest tests/Features/Tia/PartialRunWriteTier.php
```
@AGENTS.md
+9 -3
View File
@@ -29,13 +29,19 @@
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
- **[PhpStorm](https://jb.gg/nuno)**
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
- **[CMS Max](https://cmsmax.com/?ref=nunomaduro)**
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
- **[NativePHP](https://nativephp.com/?ref=nunomaduro)**
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
- [Zapiet](https://zapiet.com/?ref=nunomaduro)
- [LoadForge](https://loadforge.com/?ref=nunomaduro)
- [Route4Me](https://route4me.com/?ref=nunomaduro)
- [Nerdify](https://getnerdify.com/?ref=nunomaduro)
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
+19
View File
@@ -190,6 +190,24 @@ async function listPageFiles(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) {
@@ -199,6 +217,7 @@ async function discoverPagesDir() {
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
}
+10 -9
View File
@@ -18,19 +18,20 @@
],
"require": {
"php": "^8.4",
"brianium/paratest": "^7.23.1",
"brianium/paratest": "^7.24.1",
"nunomaduro/collision": "^8.9.5",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^5.0.1",
"pestphp/pest-plugin-mutate": "^5.0.2",
"pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^13.2.6",
"symfony/process": "^8.1.0"
"phpunit/phpunit": "^13.3.1",
"symfony/process": "^8.1.5"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">13.2.6",
"laravel/boost": "<2.6.0",
"phpunit/phpunit": ">13.3.1",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@@ -60,9 +61,9 @@
},
"require-dev": {
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-phpstan": "^5.0.0",
"pestphp/pest-plugin-rector": "^5.0.3",
"pestphp/pest-plugin-browser": "^5.0.1",
"pestphp/pest-plugin-phpstan": "^5.2.0",
"pestphp/pest-plugin-rector": "^5.0.4",
"pestphp/pest-plugin-type-coverage": "^5.0.2",
"psy/psysh": "^0.12.24"
},
@@ -96,7 +97,7 @@
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v",
"test:tia": "php bin/pest --group=tia -v",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots --exclude-group=tia",
"test": [
"@test:lint",
"@test:type:check",
+11 -76
View File
@@ -16,42 +16,32 @@ namespace PHPUnit\Runner\Filter;
use Pest\Contracts\HasPrintableTestCaseName;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\PhptTestCase;
use PHPUnit\Runner\Phpt\TestCase as PhptTestCase;
use RecursiveFilterIterator;
use RecursiveIterator;
use function end;
use function preg_match;
use function sprintf;
use function str_replace;
use function trim;
/**
* @extends RecursiveFilterIterator<int, Test, RecursiveIterator<int, Test>>
*
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
abstract class NameFilterIterator extends RecursiveFilterIterator
{
/**
* @psalm-var non-empty-string
*/
private readonly string $regularExpression;
private readonly ?int $dataSetMinimum;
private readonly ?int $dataSetMaximum;
private readonly CompiledNameFilter $filter;
/**
* @psalm-param RecursiveIterator<int, Test> $iterator
* @psalm-param non-empty-string $filter
* @param RecursiveIterator<int, Test> $iterator
* @param non-empty-string $filter
*/
public function __construct(RecursiveIterator $iterator, string $filter)
{
parent::__construct($iterator);
$preparedFilter = $this->prepareFilter($filter);
$this->regularExpression = $preparedFilter['regularExpression'];
$this->dataSetMinimum = $preparedFilter['dataSetMinimum'];
$this->dataSetMaximum = $preparedFilter['dataSetMaximum'];
$this->filter = CompiledNameFilter::from($filter);
}
public function accept(): bool
@@ -74,70 +64,15 @@ abstract class NameFilterIterator extends RecursiveFilterIterator
$name = $test::class.'::'.$test->nameWithDataSet();
}
$accepted = @preg_match($this->regularExpression, $name, $matches) === 1;
$accepted = @preg_match($this->filter->regularExpression(), $name, $matches) === 1;
if ($accepted && isset($this->dataSetMaximum)) {
if ($accepted && $this->filter->hasDataSetRange()) {
$set = end($matches);
$accepted = $set >= $this->dataSetMinimum && $set <= $this->dataSetMaximum;
$accepted = $set >= $this->filter->dataSetMinimum() && $set <= $this->filter->dataSetMaximum();
}
return $this->doAccept($accepted);
}
abstract protected function doAccept(bool $result): bool;
/**
* @psalm-param non-empty-string $filter
*
* @psalm-return array{regularExpression: non-empty-string, dataSetMinimum: ?int, dataSetMaximum: ?int}
*/
private function prepareFilter(string $filter): array
{
$dataSetMinimum = null;
$dataSetMaximum = null;
if (@preg_match($filter, '') === false) {
if (preg_match('/^(.*?)#(\d+)(?:-(\d+))?$/', $filter, $matches)) {
if (isset($matches[3]) && $matches[2] < $matches[3]) {
$filter = sprintf(
'%s.*with data set #(\d+)$',
$matches[1],
);
$dataSetMinimum = (int) $matches[2];
$dataSetMaximum = (int) $matches[3];
} else {
$filter = sprintf(
'%s.*with data set #%s$',
$matches[1],
$matches[2],
);
}
}
// * testDetermineJsonError@JSON_ERROR_NONE
// * testDetermineJsonError@JSON.*
elseif (preg_match('/^(.*?)@(.+)$/', $filter, $matches)) {
$filter = sprintf(
'%s.*with data set "%s"$',
$matches[1],
$matches[2],
);
}
$filter = sprintf(
'/%s/i',
str_replace(
'/',
'\\/',
$filter,
),
);
}
return [
'regularExpression' => $filter,
'dataSetMinimum' => $dataSetMinimum,
'dataSetMaximum' => $dataSetMaximum,
];
}
}
@@ -1,171 +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\ResultCache;
use const DIRECTORY_SEPARATOR;
use const LOCK_EX;
use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\Runner\DirectoryDoesNotExistException;
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 is_file;
use function json_decode;
use function json_encode;
use function Pest\version;
/**
* @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
*/
final class DefaultResultCache implements ResultCache
{
private const string DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
private readonly string $cacheFilename;
/**
* @var array<string, TestStatus>
*/
private array $defects = [];
/**
* @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(ResultCacheId $id, TestStatus $status): void
{
if ($status->isSuccess()) {
return;
}
$this->defects[$id->asString()] = $status;
}
public function status(ResultCacheId $id): TestStatus
{
return $this->defects[$id->asString()] ?? TestStatus::unknown();
}
public function setTime(ResultCacheId $id, float $time): void
{
$this->times[$id->asString()] = $time;
}
public function time(ResultCacheId $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;
}
foreach ($other->times as $id => $time) {
$this->times[$id] = $time;
}
}
public function load(): void
{
if (! is_file($this->cacheFilename)) {
return;
}
$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 DirectoryDoesNotExistException(dirname($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,
);
}
private function cacheVersion(): string
{
return 'pest_'.version();
}
}
@@ -0,0 +1,302 @@
<?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();
}
}
+121 -50
View File
@@ -14,13 +14,14 @@ declare(strict_types=1);
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\ResultCache\NullResultCache;
use PHPUnit\Runner\ResultCache\ResultCache;
use PHPUnit\Runner\ResultCache\ResultCacheId;
use PHPUnit\Runner\TestRunHistory\NullTestRunHistory;
use PHPUnit\Runner\TestRunHistory\TestRunHistory;
use PHPUnit\Runner\TestRunHistory\TestRunHistoryId;
use function array_diff;
use function array_merge;
@@ -46,9 +47,13 @@ final class TestSuiteSorter
public const int ORDER_DEFECTS_FIRST = 3;
public const int ORDER_DURATION = 4;
public const int ORDER_DURATION_ASCENDING = 4;
public const int ORDER_SIZE = 5;
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>
@@ -65,11 +70,11 @@ final class TestSuiteSorter
*/
private array $defectSortOrder = [];
private readonly ResultCache $cache;
private readonly TestRunHistory $testRunHistory;
public function __construct(?ResultCache $cache = null)
public function __construct(?TestRunHistory $testRunHistory = null)
{
$this->cache = $cache ?? new NullResultCache;
$this->testRunHistory = $testRunHistory ?? new NullTestRunHistory;
}
/**
@@ -81,8 +86,10 @@ final class TestSuiteSorter
self::ORDER_DEFAULT,
self::ORDER_REVERSED,
self::ORDER_RANDOMIZED,
self::ORDER_DURATION,
self::ORDER_SIZE,
self::ORDER_DURATION_ASCENDING,
self::ORDER_SIZE_ASCENDING,
self::ORDER_DURATION_DESCENDING,
self::ORDER_SIZE_DESCENDING,
];
if (! in_array($order, $allowedOrders, true)) {
@@ -102,6 +109,10 @@ final class TestSuiteSorter
// @codeCoverageIgnoreEnd
}
if ($suite instanceof IterativeTestSuite) {
return;
}
if ($suite instanceof TestSuite) {
foreach ($suite as $_suite) {
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
@@ -117,31 +128,37 @@ final class TestSuiteSorter
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
if ($suite->tests() === []) {
$tests = $suite->tests();
if ($tests === []) {
return;
}
if ($order === self::ORDER_REVERSED) {
$suite->setTests($this->reverse($suite->tests()));
$tests = $this->reverse($tests);
} elseif ($order === self::ORDER_RANDOMIZED) {
$suite->setTests($this->randomize($suite->tests()));
} elseif ($order === self::ORDER_DURATION) {
$suite->setTests($this->sortByDuration($suite->tests()));
} elseif ($order === self::ORDER_SIZE) {
$suite->setTests($this->sortBySize($suite->tests()));
$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) {
$suite->setTests($this->sortDefectsFirst($suite->tests()));
$tests = $this->sortDefectsFirst($tests);
}
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
$tests = $suite->tests();
/** @noinspection PhpParamsInspection */
/** @phpstan-ignore argument.type */
$suite->setTests($this->resolveDependencies($tests));
$tests = $this->resolveDependencies($tests);
}
$suite->setTests($tests);
}
private function addSuiteToDefectSortOrder(TestSuite $suite): void
@@ -154,9 +171,10 @@ final class TestSuiteSorter
$sortId = $test->sortId();
if (! isset($this->defectSortOrder[$sortId])) {
$this->defectSortOrder[$sortId] = $this->cache->status(ResultCacheId::fromReorderable($test))->asInt();
$max = max($max, $this->defectSortOrder[$sortId]);
$this->defectSortOrder[$sortId] = $this->testRunHistory->status(TestRunHistoryId::fromReorderable($test))->sortWeight();
}
$max = max($max, $this->defectSortOrder[$sortId]);
}
$this->defectSortOrder[$suite->sortId()] = $max;
@@ -210,6 +228,20 @@ final class TestSuiteSorter
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>
@@ -224,6 +256,20 @@ final class TestSuiteSorter
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);
@@ -232,45 +278,72 @@ final class TestSuiteSorter
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
if ($priorityA !== $priorityB) {
return $priorityB <=> $priorityA;
}
if ($priorityA > 0 || $priorityB > 0) {
return $this->cmpDuration($a, $b);
}
return 0;
return $priorityB <=> $priorityA;
}
private function cmpDuration(Test $a, Test $b): int
{
if (! ($a instanceof Reorderable && $b instanceof Reorderable)) {
return 0;
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;
}
return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b));
if ($test instanceof Reorderable) {
return $this->testRunHistory->time(TestRunHistoryId::fromReorderable($test));
}
return 0.0;
}
private function cmpSize(Test $a, Test $b): int
{
$sizeA = ($a instanceof TestCase || $a instanceof DataProviderTestSuite)
? $a->size()->asString()
: 'unknown';
$sizeB = ($b instanceof TestCase || $b instanceof DataProviderTestSuite)
? $b->size()->asString()
: 'unknown';
return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB];
return $this->sizeWeight($a) <=> $this->sizeWeight($b);
}
/**
* @param array<TestCase> $tests
* @return array<TestCase>
* @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
{
// when no test uses `->depends()` / PHPUnit `@depends`.
if (! $this->anyTestHasDependencies($tests)) {
return $tests;
}
@@ -279,7 +352,7 @@ final class TestSuiteSorter
$i = 0;
$provided = [];
do {
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));
@@ -287,14 +360,12 @@ final class TestSuiteSorter
} else {
$i++;
}
} while ($tests !== [] && ($i < count($tests)));
}
return array_merge($newTestOrder, $tests);
}
/**
* Cheaply determines whether any test in the tree declares @depends.
*
* @param iterable<Test> $tests
*/
private function anyTestHasDependencies(iterable $tests): bool
@@ -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`
+1 -1
View File
@@ -18,7 +18,7 @@ final class BootOverrides implements Bootstrapper
public const array FILES = [
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestRunHistory/DefaultTestRunHistory.php',
'Runner/TestSuiteLoader.php',
'Runner/TestSuiteSorter.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
+17 -6
View File
@@ -344,6 +344,8 @@ trait Testable
$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) {
@@ -408,16 +410,25 @@ trait Testable
private function __resolveTestArguments(array $arguments): array
{
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
if ($method->repetitions > 1) {
$firstArgument = array_shift($arguments);
$arguments[] = $firstArgument;
}
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
$testParameterTypes = array_values($testParameterTypesByName);
if ($method->repetitions > 1) {
$firstArgument = 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;
}
}
}
if (count($arguments) !== 1) {
foreach ($arguments as $argumentIndex => $argumentValue) {
if (! $argumentValue instanceof Closure) {
+40 -1
View File
@@ -34,6 +34,7 @@ use Pest\Support\Reflection;
use PHPUnit\Architecture\Elements\ObjectDescription;
use PHPUnit\Framework\ExpectationFailedException;
use ReflectionEnum;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionProperty;
@@ -329,8 +330,9 @@ final class Expectation
}
$closure = $this->getExpectationClosure($method);
$reflectionClosure = new \ReflectionFunction($closure);
$reflectionClosure = new ReflectionFunction($closure);
$expectation = $reflectionClosure->getClosureThis();
$parameters = $this->positionalParameters($reflectionClosure, $parameters);
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
return $closure(...$parameters);
@@ -346,6 +348,43 @@ final class Expectation
return $this;
}
/**
* @param array<array-key, mixed> $parameters
* @return array<array-key, mixed>
*/
private function positionalParameters(ReflectionFunction $closure, array $parameters): array
{
if ($parameters === [] || array_is_list($parameters)) {
return $parameters;
}
$positional = [];
foreach ($closure->getParameters() as $position => $parameter) {
if ($parameter->isVariadic()) {
return $parameters;
}
$name = $parameter->getName();
if (array_key_exists($position, $parameters)) {
$positional[] = $parameters[$position];
unset($parameters[$position]);
} elseif (array_key_exists($name, $parameters)) {
$positional[] = $parameters[$name];
unset($parameters[$name]);
} elseif ($parameter->isDefaultValueAvailable()) {
$positional[] = $parameter->getDefaultValue();
} else {
return $parameters;
}
}
return $parameters === [] ? $positional : $parameters;
}
/**
* @throws ExpectationNotFound
*/
+1 -1
View File
@@ -37,9 +37,9 @@ final class Kernel
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootView::class,
Bootstrappers\BootKernelDump::class,
Bootstrappers\BootExcludeList::class,
+31 -27
View File
@@ -14,7 +14,7 @@ use InvalidArgumentException;
use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue;
use Pest\Matchers\Any;
use Pest\Plugins\Snapshot;
use Pest\Plugins\Snapshot as SnapshotPlugin;
use Pest\Support\Arr;
use Pest\Support\Exporter;
use Pest\Support\NullClosure;
@@ -712,10 +712,11 @@ final class Expectation
/**
* @return self<TValue>
*/
public function toMatchSnapshot(string $message = ''): self
public function toMatchSnapshot(string $message = '', ?string $as = null): self
{
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->startNewExpectation();
$snapshot = $as === null ? $snapshots->next() : $snapshots->named($as);
$testCase = TestSuite::getInstance()->test;
assert($testCase instanceof TestCase);
@@ -733,33 +734,36 @@ final class Expectation
default => InvalidExpectationValue::expected('array|object|string'),
};
if (! $snapshots->has()) {
$filename = $snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
} else {
[$filename, $content] = $snapshots->get();
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
$snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
} else {
if (Snapshot::$updateSnapshots) {
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
}
Assert::assertSame(
$normalizedContent,
$normalizedString,
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
if (! $snapshot->exists()) {
if (! SnapshotPlugin::shouldCreateMissingSnapshots()) {
Assert::fail($message === '' ? "Snapshot is missing at [{$snapshot->path()}]. Run Pest with --update-snapshots to create it." : $message);
}
$snapshot->write($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [{$snapshot->path()}]");
return $this;
}
if (SnapshotPlugin::$updateSnapshots) {
if (! $snapshot->matches($string)) {
$snapshot->write($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [{$snapshot->path()}]");
return $this;
}
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [{$snapshot->path()}]");
}
Assert::assertSame(
$snapshot->normalize($snapshot->read()),
$snapshot->normalize($string),
$message === '' ? "Failed asserting that the string value matches its snapshot ({$snapshot->path()})." : $message
);
return $this;
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '5.0.5';
return '5.1.3';
}
function testDirectory(string $file = ''): string
+10 -2
View File
@@ -44,10 +44,18 @@ final class Cache implements HandlesArguments
}
}
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
return $this->pushArgument('--cache-result', $arguments);
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasTestRunHistoryArgument($arguments)) {
return $this->pushArgument('--record-test-run-history', $arguments);
}
return $arguments;
}
/**
* @param array<int, string> $arguments
*/
private function hasTestRunHistoryArgument(array $arguments): bool
{
return array_any(['--record-test-run-history', '--do-not-record-test-run-history', '--cache-result', '--do-not-cache-result'], fn (string $argument): bool => $this->hasArgument($argument, $arguments));
}
}
@@ -20,6 +20,7 @@ final class Parallel implements HandlesArguments
'-p',
'--no-output',
'--cache-result',
'--record-test-run-history',
];
public function handleArguments(array $arguments): array
@@ -21,7 +21,7 @@ use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\TestRunner\WarningTriggered;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
use PHPUnit\Runner\TestRunHistory\DefaultTestRunHistory;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
@@ -417,6 +417,9 @@ final class WrapperRunner implements RunnerInterface
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
self::numberOfDeprecationsByTrigger($testResultSum, $testResult),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->retriedTests(), $testResult->retriedTests()),
);
}
@@ -455,14 +458,16 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->phpNotices(),
$testResultSum->phpWarnings(),
$testResultSum->numberOfIssuesIgnoredByBaseline(),
self::numberOfDeprecationsByTrigger($testResultSum),
$testResultSum->retriedTests(),
);
self::$result = $testResultSum;
if ($this->options->configuration->cacheResult()) {
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
if ($this->options->configuration->recordTestRunHistory()) {
$resultCacheSum = new DefaultTestRunHistory($this->options->configuration->testRunHistoryFile());
foreach ($this->resultCacheFiles as $resultCacheFile) {
$resultCache = new DefaultResultCache($resultCacheFile->getPathname());
$resultCache = new DefaultTestRunHistory($resultCacheFile->getPathname());
$resultCache->load();
$resultCacheSum->mergeWith($resultCache);
@@ -495,6 +500,28 @@ final class WrapperRunner implements RunnerInterface
return $exitcode;
}
/**
* @return array{self: non-negative-int, direct: non-negative-int, indirect: non-negative-int, unknown: non-negative-int}
*/
private static function numberOfDeprecationsByTrigger(TestResult ...$results): array
{
$self = $direct = $indirect = $unknown = 0;
foreach ($results as $result) {
$self += max(0, $result->numberOfSelfDeprecations());
$direct += max(0, $result->numberOfDirectDeprecations());
$indirect += max(0, $result->numberOfIndirectDeprecations());
$unknown += max(0, $result->numberOfDeprecationsWithUnknownTrigger());
}
return [
'self' => $self,
'direct' => $direct,
'indirect' => $indirect,
'unknown' => $unknown,
];
}
private function generateCodeCoverageReports(): void
{
if ($this->coverageFiles === []) {
+7 -1
View File
@@ -132,7 +132,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
$this->ensureFilterLengthIsSafe($filter);
return [...$arguments, '--filter', $filter];
$arguments = [...$arguments, '--filter', $filter];
if ($this->hasArgument('--do-not-fail-on-empty-test-suite', $arguments) || $this->hasArgument('--fail-on-empty-test-suite', $arguments)) {
return $arguments;
}
return $this->pushArgument('--do-not-fail-on-empty-test-suite', $arguments);
}
/**
+39
View File
@@ -16,6 +16,36 @@ final class Snapshot implements HandlesArguments
public static bool $updateSnapshots = false;
/**
* @var list<string>
*/
private const array CI_ENVIRONMENT_VARIABLES = [
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'APPVEYOR',
'BITBUCKET_BUILD_NUMBER',
'BUILDKITE',
'TEAMCITY_VERSION',
'JENKINS_URL',
'SYSTEM_COLLECTIONURI',
'CI_NAME',
'TASKCLUSTER_ROOT_URL',
'DRONE',
'WERCKER',
'NEVERCODE',
'SEMAPHORE',
'NETLIFY',
'NOW_BUILDER',
];
public static function shouldCreateMissingSnapshots(): bool
{
return self::$updateSnapshots || ! self::runningOnCI();
}
/**
* {@inheritDoc}
*/
@@ -119,4 +149,13 @@ final class Snapshot implements HandlesArguments
return true;
}
private static function runningOnCI(): bool
{
if (Environment::name() === Environment::CI) {
return true;
}
return array_any(self::CI_ENVIRONMENT_VARIABLES, fn (string $environmentVariable): bool => getenv($environmentVariable) !== false);
}
}
+39 -1
View File
@@ -64,6 +64,8 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
private const string BASELINE_PATH_OPTION = '--baseline';
private const string MUTATE_OPTION = '--mutate';
private const string ENV_MUTATION_TESTING = 'PEST_MUTATION_TESTING';
private const string ENV_TIA = 'PEST_TIA';
@@ -198,6 +200,8 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
private bool $graphUnreachable = false;
private bool $fullSuiteFallbackRan = false;
/** @var array<int, string> */
private array $originalArguments = [];
@@ -329,6 +333,18 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return ! self::argumentPresent('--ci', $arguments);
}
/**
* @param array<int, string> $arguments
*/
public static function isMutationRun(array $arguments): bool
{
if (self::argumentPresent(self::MUTATE_OPTION, $arguments)) {
return true;
}
return getenv(self::ENV_MUTATION_TESTING) !== false;
}
public static function recordsEdgesInWorkers(): bool
{
return (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'
@@ -472,6 +488,8 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$partial = ! $isWorker && ($hasExplicitPath || $this->hasPartialSelection($arguments));
$disabled = $disabled || $partial;
$mutating = self::isMutationRun($this->originalArguments);
if (getenv(self::ENV_MUTATION_TESTING) !== false) {
$this->writesSuppressed = true;
}
@@ -508,6 +526,16 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return $arguments;
}
if (! $isWorker && $mutating && ($enabled || $this->forceRefetch)) {
$this->requestWorkerResults();
$this->emitMutationScopedRecordSkipped();
$this->forceRefetch = false;
$this->filteredMode = false;
return $arguments;
}
if ($isWorker && (string) Parallel::getGlobal(self::WORKER_RESULTS_GLOBAL) === '1') {
$this->flushesWorkerResults = true;
$this->resultsOnlyWrites = true;
@@ -658,7 +686,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
return $exitCode;
}
if ($this->replayRan || $this->graphUnreachable) {
if ($this->replayRan || $this->graphUnreachable || $this->fullSuiteFallbackRan) {
$this->bumpRecordedSha();
}
@@ -1018,6 +1046,8 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
$this->fullSuiteFallbackRan = true;
$this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.');
$this->renderChild('Running the full suite to avoid using a stale dependency graph.');
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
@@ -1286,6 +1316,14 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
$this->renderChild('Record the baseline with a plain --tia run first; coverage runs then reuse it.');
}
private function emitMutationScopedRecordSkipped(): void
{
$this->output->writeln('');
$this->renderChild('Running in TIA mode, however TIA is skipped as mutation testing needs the coverage of a complete run.');
$this->renderChild('Drop --mutate to narrow and replay with TIA.');
}
/**
* @param array<string, array<int, string>> $perTestFiles
* @param array<string, array<int, string>> $perTestTables
+10
View File
@@ -11,6 +11,16 @@ use Pest\Support\Container;
*/
final class Configuration
{
/**
* @return $this
*/
public function directory(string $directory): self
{
Storage::useDirectory($directory);
return $this;
}
/**
* @return $this
*/
+24 -2
View File
@@ -57,7 +57,7 @@ final class CoverageMerger
}
self::primeUncoveredFiles($cached);
self::primeUncoveredFiles($current);
self::discardUnexercisedFiles($current);
self::stripCurrentTestsFromCached($cached, $current);
@@ -77,6 +77,28 @@ final class CoverageMerger
$coverage->getData(false);
}
private static function discardUnexercisedFiles(CodeCoverage $coverage): void
{
$data = $coverage->getData(true);
$lineCoverage = $data->lineCoverage();
$discarded = false;
foreach ($lineCoverage as $file => $lines) {
foreach ($lines as $hits) {
if (is_array($hits) && $hits !== []) {
continue 2;
}
}
unset($lineCoverage[$file]);
$discarded = true;
}
if ($discarded) {
$data->setLineCoverage($lineCoverage);
}
}
private static function compress(string $bytes): string
{
$compressed = @gzencode($bytes);
@@ -139,7 +161,7 @@ final class CoverageMerger
*/
private static function collectTestIds(CodeCoverage $coverage): array
{
$data = $coverage->getData();
$data = $coverage->getData(true);
$idByIndex = $data->testIds();
$ids = [];
+1 -1
View File
@@ -40,7 +40,7 @@ final readonly class Fingerprint
'js_config' => self::jsConfigHash($projectRoot),
],
'environmental' => [
'php_minor' => PHP_MAJOR_VERSION,
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
],
];
+39 -12
View File
@@ -85,6 +85,10 @@ final class JsModuleGraph
continue;
}
if (! self::matchesDiskCasing($projectRoot, $rel)) {
continue;
}
if (self::dirHasPageFile($abs)) {
return $abs;
}
@@ -93,12 +97,30 @@ final class JsModuleGraph
return null;
}
private static function matchesDiskCasing(string $projectRoot, string $relative): bool
{
$current = rtrim($projectRoot, DIRECTORY_SEPARATOR);
foreach (explode('/', $relative) as $segment) {
$entries = @scandir($current);
if ($entries === false || ! in_array($segment, $entries, true)) {
return false;
}
$current .= DIRECTORY_SEPARATOR.$segment;
}
return true;
}
private static function dirHasPageFile(string $dir): bool
{
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
\RecursiveIteratorIterator::CATCH_GET_CHILD,
);
} catch (\UnexpectedValueException) {
return false;
@@ -265,20 +287,25 @@ final class JsModuleGraph
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
\RecursiveIteratorIterator::CATCH_GET_CHILD,
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
} catch (\UnexpectedValueException|\RuntimeException) {
return null;
}
sort($entries);
+1 -1
View File
@@ -117,7 +117,7 @@ final class ResultCollector
public function hasUnfinishedTest(): bool
{
return $this->currentTestId !== null;
return $this->currentTestId !== null && ! isset($this->results[$this->currentTestId]);
}
public function recordAssertions(string $testId, int $assertions): void
+14
View File
@@ -9,8 +9,17 @@ namespace Pest\Plugins\Tia;
*/
final class Storage
{
private static ?string $directory = null;
public static function tempDir(string $projectRoot): string
{
if (self::$directory !== null) {
$isAbsolute = str_starts_with(self::$directory, DIRECTORY_SEPARATOR)
|| preg_match('/^[a-z]:[\\\\\/]/i', self::$directory) === 1;
return $isAbsolute ? self::$directory : $projectRoot.DIRECTORY_SEPARATOR.self::$directory;
}
$home = self::homeDir();
if ($home === null) {
@@ -25,6 +34,11 @@ final class Storage
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
}
public static function useDirectory(?string $directory): void
{
self::$directory = $directory;
}
public static function purge(string $projectRoot): void
{
$dir = self::tempDir($projectRoot);
+1 -1
View File
@@ -141,7 +141,7 @@ final class DatasetsRepository
$datasets[$index] = self::getScopedDataset($data, $currentTestFile);
}
if (is_callable($datasets[$index])) {
if (! is_array($datasets[$index]) && is_callable($datasets[$index])) {
$datasets[$index] = call_user_func($datasets[$index]);
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
use Pest\Exceptions\ShouldNotHappen;
/**
* @internal
*/
final readonly class Snapshot
{
public function __construct(
private string $filename,
private string $basePath,
) {}
public function exists(): bool
{
return file_exists($this->filename);
}
public function path(): string
{
return str_replace($this->basePath, '', $this->filename);
}
/**
* @throws ShouldNotHappen
*/
public function read(): string
{
$contents = file_get_contents($this->filename);
if ($contents === false) {
throw ShouldNotHappen::fromMessage('Snapshot file could not be read.');
}
return $contents;
}
public function write(string $contents): self
{
$directory = dirname($this->filename);
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
}
file_put_contents($this->filename, $contents);
return $this;
}
/**
* @throws ShouldNotHappen
*/
public function matches(string $value): bool
{
return $this->normalize($this->read()) === $this->normalize($value);
}
public function normalize(string $value): string
{
return strtr($value, ["\r\n" => "\n", "\r" => "\n"]);
}
}
+83 -50
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Repositories;
use InvalidArgumentException;
use Pest\Exceptions\ShouldNotHappen;
use Pest\TestSuite;
@@ -12,8 +13,9 @@ use Pest\TestSuite;
*/
final class SnapshotRepository
{
/** @var array<string, int> */
private static array $expectationsCounter = [];
private static ?string $key = null;
private static int $ordinal = 0;
public function __construct(
private readonly string $rootPath,
@@ -21,42 +23,39 @@ final class SnapshotRepository
private readonly string $snapshotsPath,
) {}
public function has(): bool
public function current(): Snapshot
{
return file_exists($this->getSnapshotFilename());
$this->synchronize();
return $this->snapshot(self::$ordinal > 1 ? '__'.self::$ordinal : '');
}
/**
* @return array{0: string, 1: string}
*
* @throws ShouldNotHappen
*/
public function get(): array
public function next(): Snapshot
{
$contents = file_get_contents($snapshotFilename = $this->getSnapshotFilename());
$this->synchronize();
if ($contents === false) {
throw ShouldNotHappen::fromMessage('Snapshot file could not be read.');
}
self::$ordinal++;
$snapshot = str_replace(dirname($this->testsPath).'/', '', $snapshotFilename);
return [$snapshot, $contents];
return $this->current();
}
public function save(string $snapshot): string
public function named(string $name): Snapshot
{
$snapshotFilename = $this->getSnapshotFilename();
$this->synchronize();
$directory = dirname($snapshotFilename);
$suffix = trim((string) preg_replace('/[^\w-]+/', '_', $name), '_');
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
if ($suffix === '') {
throw new InvalidArgumentException('The snapshot name must contain at least one alphanumeric character.');
}
file_put_contents($snapshotFilename, $snapshot);
return $this->snapshot('__'.$suffix);
}
return str_replace(dirname($this->testsPath).'/', '', $snapshotFilename);
public function forget(): void
{
self::$key = null;
self::$ordinal = 0;
}
public function flush(): void
@@ -87,47 +86,81 @@ final class SnapshotRepository
}
}
private function getSnapshotFilename(): string
private function snapshot(string $suffix): Snapshot
{
$testFile = TestSuite::getInstance()->getFilename();
if (str_starts_with($testFile, $this->testsPath)) {
$startPath = $this->testsPath;
} else {
$startPath = $this->rootPath;
}
$startPath = str_starts_with($testFile, $this->testsPath) ? $this->testsPath : $this->rootPath;
$relativePath = substr($testFile, strlen($startPath));
$relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.'));
$description = TestSuite::getInstance()->getDescription();
return new Snapshot(
sprintf(
'%s/%s%s.snap',
$this->testsPath.'/'.$this->snapshotsPath.$relativePath,
TestSuite::getInstance()->getDescription(),
$suffix,
),
dirname($this->testsPath).'/',
);
}
if ($this->getCurrentSnapshotCounter() > 1) {
$description .= '__'.$this->getCurrentSnapshotCounter();
private function synchronize(): void
{
$key = TestSuite::getInstance()->getFilename().'###'.TestSuite::getInstance()->getDescription();
if (self::$key === $key) {
return;
}
return sprintf('%s/%s.snap', $this->testsPath.'/'.$this->snapshotsPath.$relativePath, $description);
}
private function getCurrentSnapshotKey(): string
{
return TestSuite::getInstance()->getFilename().'###'.TestSuite::getInstance()->getDescription();
}
private function getCurrentSnapshotCounter(): int
{
return self::$expectationsCounter[$this->getCurrentSnapshotKey()] ?? 0;
self::$key = $key;
self::$ordinal = 0;
}
/**
* @deprecated Use `next` and `current` instead.
*/
public function startNewExpectation(): void
{
$key = $this->getCurrentSnapshotKey();
$this->next();
}
if (! isset(self::$expectationsCounter[$key])) {
self::$expectationsCounter[$key] = 0;
}
/**
* @deprecated Use `current` instead.
*/
public function has(): bool
{
return $this->current()->exists();
}
self::$expectationsCounter[$key]++;
/**
* @deprecated Use `current` instead.
*
* @return array{0: string, 1: string}
*
* @throws ShouldNotHappen
*/
public function get(): array
{
$snapshot = $this->current();
return [$snapshot->path(), $snapshot->read()];
}
/**
* @deprecated Use `current` instead.
*/
public function save(string $snapshot): string
{
return $this->current()->write($snapshot)->path();
}
/**
* @deprecated Use `current` instead.
*/
public function filename(): string
{
return $this->current()->path();
}
}
+19 -6
View File
@@ -52,13 +52,8 @@ final class PcovRestarter implements Restarter
$env = $this->inheritEnv();
$env[self::ENV_RESTARTED] = '1';
$command = array_merge(
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
array_values($arguments),
);
$proc = @proc_open(
$command,
$this->command($projectRoot, $arguments),
[STDIN, STDOUT, STDERR],
$pipes,
null,
@@ -74,6 +69,24 @@ final class PcovRestarter implements Restarter
exit($exitCode === -1 ? 1 : $exitCode);
}
/**
* @param array<int, string> $arguments
* @return list<string>
*/
private function command(string $projectRoot, array $arguments): array
{
return array_merge(
[
PHP_BINARY,
'-d',
'memory_limit='.ini_get('memory_limit'),
'-d',
'pcov.directory='.$projectRoot,
],
array_values($arguments),
);
}
/**
* @return array<string, string>
*/
+4
View File
@@ -78,6 +78,10 @@ final class XdebugRestarter implements Restarter
}
}
if (Tia::isMutationRun($arguments)) {
return false;
}
if (! Tia::isEnabledForRun($arguments)) {
return false;
}
@@ -15,8 +15,8 @@ beforeEach(function () {
});
test('pass with dataset', function ($data) {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
$filename = TestSuite::getInstance()->snapshots->current()->path();
expect($filename)->toStartWith('tests/.pest/snapshots-external/')
->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value___.snap')
@@ -25,8 +25,8 @@ test('pass with dataset', function ($data) {
describe('within describe', function () {
test('pass with dataset', function ($data) {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
$filename = TestSuite::getInstance()->snapshots->current()->path();
expect($filename)->toStartWith('tests/.pest/snapshots-external/')
->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value___.snap')
@@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>
@@ -0,0 +1 @@
the same on every attempt
@@ -1,5 +1,5 @@
Pest Testing Framework 5.0.5.
Pest Testing Framework 5.1.3.
USAGE: pest <file> [options]
@@ -77,6 +77,9 @@
--fail-on-warning Signal failure using shell exit code when a warning was triggered
--fail-on-risky Signal failure using shell exit code when a test was considered risky
--fail-on-deprecation Signal failure using shell exit code when a deprecation was triggered
--fail-on-self-deprecation Signal failure using shell exit code when a deprecation was triggered in first-party code
--fail-on-direct-deprecation Signal failure using shell exit code when a deprecation was triggered in third-party code called from first-party code
--fail-on-indirect-deprecation Signal failure using shell exit code when a deprecation was triggered in third-party code called from third-party code
--fail-on-phpunit-deprecation Signal failure using shell exit code when a PHPUnit deprecation was triggered
--fail-on-phpunit-notice Signal failure using shell exit code when a PHPUnit notice was triggered
--fail-on-phpunit-warning Signal failure using shell exit code when a PHPUnit warning was triggered
@@ -88,19 +91,26 @@
--do-not-fail-on-warning Do not signal failure using shell exit code when a warning was triggered
--do-not-fail-on-risky Do not signal failure using shell exit code when a test was considered risky
--do-not-fail-on-deprecation Do not signal failure using shell exit code when a deprecation was triggered
--do-not-fail-on-self-deprecation Do not signal failure using shell exit code when a deprecation was triggered in first-party code
--do-not-fail-on-direct-deprecation Do not signal failure using shell exit code when a deprecation was triggered in third-party code called from first-party code
--do-not-fail-on-indirect-deprecation Do not signal failure using shell exit code when a deprecation was triggered in third-party code called from third-party code
--do-not-fail-on-phpunit-deprecation Do not signal failure using shell exit code when a PHPUnit deprecation was triggered
--do-not-fail-on-phpunit-notice Do not signal failure using shell exit code when a PHPUnit notice was triggered
--do-not-fail-on-phpunit-warning Do not signal failure using shell exit code when a PHPUnit warning was triggered
--do-not-fail-on-notice Do not signal failure using shell exit code when a notice was triggered
--do-not-fail-on-skipped Do not signal failure using shell exit code when a test was skipped
--do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete
--cache-result ............................ Write test results to cache file
--do-not-cache-result .............. Do not write test results to cache file
--record-test-run-history .......................... Record test run history
--do-not-record-test-run-history ............ Do not record test run history
--warn-when-php-is-not-configured-for-development Trigger a test runner warning when PHP is not configured for development
--do-not-warn-when-php-is-not-configured-for-development Do not trigger a test runner warning when PHP is not configured for development
--order-by [order] Run tests in order: default|defects|depends|duration-ascending|duration-descending|no-depends|random|reverse|size-ascending|size-descending
--resolve-dependencies ...................... Alias for "--order-by depends"
--ignore-dependencies .................... Alias for "--order-by no-depends"
--random-order ............................... Alias for "--order-by random"
--random-order-seed [N] Use the specified random seed when running tests in random order
--repeat [N] .......... Run each test N times, stopping at the first failure
--retry [N] . Attempt each test up to N times, stopping at the first success
--reverse-order ............................. Alias for "--order-by reverse"
REPORTING OPTIONS:
@@ -151,6 +161,8 @@
--coverage-cobertura [file] Write code coverage report in Cobertura XML format to file
--coverage-crap4j [file] Write code coverage report in Crap4J XML format to file
--coverage-html [dir] Write code coverage report in HTML format to directory
--without-class-view Render code coverage report in HTML format without class view
--without-file-view Render code coverage report in HTML format without file view
--coverage-php [file] .......... Write serialized code coverage data to file
--coverage-text=[file] Write code coverage report in text format to file [default: standard output]
--only-summary-for-coverage-text Option for code coverage report in text format: only show summary
@@ -1,3 +1,3 @@
Pest Testing Framework 5.0.5.
Pest Testing Framework 5.1.3.
+46 -230
View File
@@ -73,9 +73,9 @@
↓ is marked as todo 3
↓ shouldBeMarkedAsTodo
PASS Tests\Features\Coverage
WARN Tests\Features\Coverage
✓ it has plugin
it adds coverage if --coverage exist
- it adds coverage if --coverage exist → Coverage is not available
✓ it adds coverage if --min exist
✓ it generates coverage based on file input
@@ -439,6 +439,7 @@
✓ interceptor can be filter the expected parameter as well
✓ interceptor works with negated expectation
✓ intercept can add new parameters to the expectation
✓ named arguments reach the pipes of an expectation
PASS Tests\Features\Expect\ray
✓ ray calls do not fail when ray is not installed
@@ -1122,6 +1123,17 @@
✓ multiple snapshot expectations with repeat @ repetition 8 of 10
✓ multiple snapshot expectations with repeat @ repetition 9 of 10
✓ multiple snapshot expectations with repeat @ repetition 10 of 10
✓ pass with named snapshot
✓ named snapshots do not depend on the order they are asserted in
✓ named snapshots do not consume the ordinal of the unnamed ones
✓ named snapshots require a name
✓ ordinal snapshots start over once forgotten
✓ ordinal snapshots start over on every repetition @ repetition 1 of 3
✓ ordinal snapshots start over on every repetition @ repetition 2 of 3
✓ ordinal snapshots start over on every repetition @ repetition 3 of 3
✓ snapshots of a repeated test are recorded per repetition @ repetition 1 of 3
✓ snapshots of a repeated test are recorded per repetition @ repetition 2 of 3
✓ snapshots of a repeated test are recorded per repetition @ repetition 3 of 3
PASS Tests\Features\Expect\toStartWith
✓ pass
@@ -1224,9 +1236,11 @@
✓ it does not retry fails()
✓ it retries unexpected exceptions even with throws set
✓ it does not leak mock objects between retries
✓ it resolves the same snapshot ordinals on every retry
✓ it matches the same snapshot on every retry
✓ it does not stop retrying when snapshot changes are absent
✓ it does not leak dynamic properties between retries
it clears output buffer between retries when expectOutputString is used
! it clears output buffer between retries when expectOutputString is used → Creation of dynamic property PHPUnit\Framework\TestCase\OutputBuffer::$expectedString is deprecated
✓ it preserves output between retries when no output expectation is set
WARN Tests\Features\Helpers
@@ -1455,6 +1469,14 @@
✓ multiple times with repeat iterator with multiple dataset ('c') / ('d') @ repetition 2 of 2
✓ multiple times with repeat iterator with multiple dataset ('c') / ('e') @ repetition 2 of 2
✓ multiple times with repeat iterator with multiple dataset ('c') / ('f') @ repetition 2 of 2
✓ multiple times with named dataset arguments ('Taylor', 'taylor@laravel.com') @ repetition 1 of 2
✓ multiple times with named dataset arguments ('Nuno', 'enunomaduro@gmail.com') @ repetition 1 of 2
✓ multiple times with named dataset arguments ('Taylor', 'taylor@laravel.com') @ repetition 2 of 2
✓ multiple times with named dataset arguments ('Nuno', 'enunomaduro@gmail.com') @ repetition 2 of 2
✓ multiple times with named dataset arguments and repeat iterator ('Taylor', 'taylor@laravel.com') @ repetition 1 of 2
✓ multiple times with named dataset arguments and repeat iterator ('Nuno', 'enunomaduro@gmail.com') @ repetition 1 of 2
✓ multiple times with named dataset arguments and repeat iterator ('Taylor', 'taylor@laravel.com') @ repetition 2 of 2
✓ multiple times with named dataset arguments and repeat iterator ('Nuno', 'enunomaduro@gmail.com') @ repetition 2 of 2
✓ describe blocks → multiple times @ repetition 1 of 3
✓ describe blocks → multiple times @ repetition 2 of 3
✓ describe blocks → multiple times @ repetition 3 of 3
@@ -1568,232 +1590,6 @@
PASS Tests\Features\Tia
✓ it does not run user hooks when replaying cached skipped and incomplete results
PASS Tests\Features\Tia\BranchShapes
✓ a branch name git allows is a branch key TIA can hold with dataset "slashes"
✓ a branch name git allows is a branch key TIA can hold with dataset "dots"
✓ a branch name git allows is a branch key TIA can hold with dataset "unicode"
✓ a branch name git allows is a branch key TIA can hold with dataset "digits"
✓ a branch name git allows is a branch key TIA can hold with dataset "underscores"
✓ a branch name git allows is a branch key TIA can hold with dataset "very long"
✓ a branch differing from the default only in case gets its own key
✓ a branch that only lives on the remote keeps its baseline
✓ a branch checked out in a worktree keeps its baseline
✓ deleting many branches reclaims every one of their baselines with dataset "sequential"
✓ deleting many branches reclaims every one of their baselines with dataset "parallel"
✓ a narrowed run does not reclaim anything
✓ a detached HEAD does not reclaim anything either
✓ the default branch baseline survives every branch that comes and goes
✓ a project below the git repository root refuses to run and writes nothing with dataset "sequential"
✓ a project below the git repository root refuses to run and writes nothing with dataset "parallel"
✓ a repository with no commits says so, and leaves plain runs alone
✓ a directory with no repository at all still asks for git
PASS Tests\Features\Tia\CompleteRunWriteTier
✓ a complete run prunes a deleted test with dataset "sequential"
✓ a complete run prunes a deleted test with dataset "parallel"
✓ a complete run records nothing for a test file the graph does not know
✓ a partial run records nothing for a test file the graph does not know
✓ a truncated run does not prune with dataset "sequential"
✓ a truncated run does not prune with dataset "parallel"
✓ a green bail run is complete
✓ --no-tia refreshes results without enabling tia with dataset "sequential"
✓ --no-tia refreshes results without enabling tia with dataset "parallel"
✓ a plain run refreshes the results it executed with dataset "sequential"
✓ a plain run refreshes the results it executed with dataset "parallel"
✓ a parallel replay keeps the recorded time of tests that did not run
✓ a run that never enables tia creates no graph with dataset "plain"
✓ a run that never enables tia creates no graph with dataset "filtered"
✓ a run that never enables tia creates no graph with dataset "parallel filtered"
✓ a test edit narrows to the affected file and replays the rest
✓ a parallel run merges worker results into the parent baseline
PASS Tests\Features\Tia\CoveragePiggyback
✓ a coverage report does not found a dependency graph with dataset "pest coverage"
✓ a coverage report does not found a dependency graph with dataset "phpunit coverage report"
✓ a coverage report does not found a dependency graph with dataset "parallel"
✓ a plain run after a coverage run records the whole project scope
✓ a coverage report leaves the edges of an existing graph alone
PASS Tests\Features\Tia\DefaultBranchReplay
✓ replays the default branch baseline on a new branch
✓ replays whatever the default branch is called with ('main')
✓ replays whatever the default branch is called with ('master')
✓ replays whatever the default branch is called with ('trunk')
✓ replays whatever the default branch is called with ('develop')
✓ replays on a second new branch too
✓ writes nothing on a second run on the same branch
✓ replays on a branch whose name contains slashes
✓ replays again once back on the default branch
✓ replays on a new branch when tia is enabled by configuration
✓ a narrowed run on a new branch does not cost the fallback with dataset "sequential"
✓ a narrowed run on a new branch does not cost the fallback with dataset "parallel"
✓ replays inside a worktree on a new branch
PASS Tests\Features\Tia\DefaultBranchResolution
✓ a declared default branch beats autodetection
✓ a declared default branch that does not exist degrades to a full run
✓ a renamed default branch replays and writes under its new name
✓ the CI provider names the default branch where the checkout cannot
✓ GitLab names the default branch through its own variable
✓ a lone recorded baseline names the default branch
✓ a default branch nothing can name is refused rather than guessed
✓ an init.defaultBranch naming a branch that exists is still trusted
✓ a repository with no remote is refused rather than silently re-run
✓ a remote-less repository holding one baseline is not refused
✓ a declared default branch stands in for a missing remote
✓ tia still requires git
✓ a plain run outside a repository creates no baseline
✓ the default branch is resolved once per run, not once per test
PASS Tests\Features\Tia\DefaultBranchWriteTier
✓ narrows to the affected tests on a new branch
✓ filtered mode reads the fallback too
✓ filtered mode finds nothing to do on a clean green feature branch
✓ filtered mode falls back to a full replay when a cached failure cannot be located
✓ filtered mode finds nothing to do on the default branch itself
✓ a detached HEAD replays without minting a branch key
✓ a detached HEAD does not write into the default branch baseline with dataset "sequential"
✓ a detached HEAD does not write into the default branch baseline with dataset "parallel"
✓ the branch that ran gets its own key and the default branch keeps its baseline
✓ the fallback reaches parallel workers
PASS Tests\Features\Tia\FilteredMode
✓ re-runs a cached failure on a clean tree
✓ an explicit path turns filtered mode off
✓ filtered mode runs the whole suite when there is no baseline
✓ filtered mode finds nothing to do in parallel either
✓ a corrupt graph is reported and does not crash the run
✓ --parallel --retry is refused and leaves the graph alone
PASS Tests\Features\Tia\HostileState
✓ a graph mangled beyond use still lets the suite run with dataset "empty"
✓ a graph mangled beyond use still lets the suite run with dataset "truncated"
✓ a graph mangled beyond use still lets the suite run with dataset "not json"
✓ a graph mangled beyond use still lets the suite run with dataset "json scalar"
✓ a graph mangled beyond use still lets the suite run with dataset "json list"
✓ a graph mangled beyond use still lets the suite run with dataset "json null"
✓ a graph mangled beyond use still lets the suite run with dataset "empty object"
✓ a graph mangled beyond use still lets the suite run with dataset "nul bytes"
✓ a graph whose shape is wrong everywhere is repaired rather than trusted with dataset "sequential"
✓ a graph whose shape is wrong everywhere is repaired rather than trusted with dataset "parallel"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "below the range"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "one past the range"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "far past the range"
✓ a cached status this build cannot interpret is re-run, not replayed with dataset "huge"
✓ a cached status with no replay of its own does not fail the run with dataset "notice"
✓ a cached status with no replay of its own does not fail the run with dataset "deprecation"
✓ a cached status with no replay of its own does not fail the run with dataset "warning"
✓ a cached skip or todo replays with its message intact with dataset "skipped"
✓ a cached skip or todo replays with its message intact with dataset "incomplete"
✓ a cached failure with a multi-line message re-runs rather than replaying the text
✓ a result pointing outside the project is not addressable and widens the run
✓ an edge pointing at a file id that does not exist is ignored
✓ a graph from a schema this build does not know is rebuilt, not read
✓ graph.json being a directory does not stop the run
✓ a state dir it cannot write to still replays
PASS Tests\Features\Tia\IssueStatuses
✓ a triggered issue is recorded as itself, not as a pass with dataset "sequential" / dataset "deprecation"
✓ a triggered issue is recorded as itself, not as a pass with dataset "sequential" / dataset "notice"
✓ a triggered issue is recorded as itself, not as a pass with dataset "sequential" / dataset "warning"
✓ a triggered issue is recorded as itself, not as a pass with dataset "parallel" / dataset "deprecation"
✓ a triggered issue is recorded as itself, not as a pass with dataset "parallel" / dataset "notice"
✓ a triggered issue is recorded as itself, not as a pass with dataset "parallel" / dataset "warning"
✓ a cached deprecation still fails the run that asked to fail on one with dataset "sequential"
✓ a cached deprecation still fails the run that asked to fail on one with dataset "parallel"
✓ replaying a cached issue does not downgrade it to a pass with dataset "sequential"
✓ replaying a cached issue does not downgrade it to a pass with dataset "parallel"
✓ a failure outranks an issue triggered on the way to it
✓ a skip outranks an issue triggered on the way to it
✓ a suppressed issue is not recorded
PASS Tests\Features\Tia\LivewireComponents
✓ a changed single-file component selects only the tests that rendered it, across workers
✓ a changed multi-file component asset selects the tests that rendered the component
✓ a deleted single-file component selects the tests that rendered it
✓ a Blade file with no generated view still falls back to the watch pattern
PASS Tests\Features\Tia\PartialRunWriteTier
✓ a filtered run rewrites only the test that ran
✓ a filtered run under --tia announces that tia does not apply
✓ a test suffix narrows the tier even though every test runs
✓ a dirty run narrows to the uncommitted test edit
✓ filtered mode yields to an explicit filter
✓ an env flag narrows exactly like the option it mirrors with ('PEST_TIA')
✓ an env flag narrows exactly like the option it mirrors with ('PEST_TIA_FILTERED')
✓ a partial run does not purge the graph even with --fresh
✓ --no-tia does not stop a partial run from refreshing its own entry
✓ two partial runs each keep the other entry
✓ a shard is a partial run
✓ a parallel partial run records the test that ran, like a sequential one
PASS Tests\Features\Tia\RemoteBaseline
✓ a published baseline is fetched instead of recorded locally
✓ a fetched baseline that will not decode is discarded rather than trusted
✓ a fetched baseline recorded against another tree is not used
✓ an artifact without a graph in it fails loudly
✓ a baseline that cannot be authenticated for fails loudly
✓ a workflow or artifact that is not there fails loudly
✓ a network failure warns and lets the suite run with dataset "querying the runs"
✓ a network failure warns and lets the suite run with dataset "downloading the artifact"
✓ no published baseline yet starts a cooldown, and a corrupt cooldown does not break the run
PASS Tests\Features\Tia\SelectionPaths
✓ a committed rename selects the tests that depended on the old path with dataset "sequential"
✓ a committed rename selects the tests that depended on the old path with dataset "parallel"
✓ an affected test file that is gone does not strand a filtered run with dataset "sequential"
✓ an affected test file that is gone does not strand a filtered run with dataset "parallel"
✓ a plain run reclaims the edge of a test file that is gone
✓ a changed view selects the test that rendered it
✓ a changed partial selects the test that rendered its ancestor with dataset "direct @include"
✓ a changed partial selects the test that rendered its ancestor with dataset "transitive @include"
✓ a changed partial selects the test that rendered its ancestor with dataset "x- component"
✓ a changed partial selects the test that rendered its ancestor with dataset "include cycle"
✓ a changed Inertia page selects the test that rendered its component
✓ a changed shared JS module selects the tests of the pages that import it
✓ a changed frontend runtime file selects every Inertia test
PASS Tests\Features\Tia\StateReclamation
✓ a detached HEAD does not purge the graph on structural drift with dataset "sequential"
✓ a detached HEAD does not purge the graph on structural drift with dataset "parallel"
✓ a detached HEAD does not purge the graph with --fresh either with dataset "sequential"
✓ a detached HEAD does not purge the graph with --fresh either with dataset "parallel"
✓ a detached HEAD leaves an unreadable graph for a checkout that can rebuild it
✓ a cached failure whose test file was deleted stops widening later runs with dataset "sequential"
✓ a cached failure whose test file was deleted stops widening later runs with dataset "parallel"
✓ a complete run reclaims the entry and the edge of a deleted test file with dataset "sequential"
✓ a complete run reclaims the entry and the edge of a deleted test file with dataset "parallel"
✓ a pruned result does not come back from the fallback with dataset "sequential"
✓ a pruned result does not come back from the fallback with dataset "parallel"
✓ the fallback still reaches a branch that has never run a test file
✓ a branch that git no longer knows loses its baseline
✓ an unknown cached status is re-run rather than replayed as a failure with dataset "unknown"
✓ an unknown cached status is re-run rather than replayed as a failure with dataset "future"
✓ an unknown cached status is re-run rather than replayed as a failure with dataset "garbage"
✓ a cached notice, deprecation or warning does not replay as a failure with dataset "notice"
✓ a cached notice, deprecation or warning does not replay as a failure with dataset "deprecation"
✓ a cached notice, deprecation or warning does not replay as a failure with dataset "warning"
✓ a malformed baseline entry cannot break the run with dataset "sequential"
✓ a malformed baseline entry cannot break the run with dataset "parallel"
✓ a run torn down mid-file does not prune the tests it never reached with dataset "sequential"
✓ a run torn down mid-file does not prune the tests it never reached with dataset "parallel"
✓ a fatal error mid-file is a test error, not a truncation with dataset "sequential"
✓ a fatal error mid-file is a test error, not a truncation with dataset "parallel"
✓ a green complete run leaves the graph exactly as it found it with dataset "bail"
✓ a green complete run leaves the graph exactly as it found it with dataset "stop-on-failure"
✓ a green complete run leaves the graph exactly as it found it with dataset "compact"
✓ a green complete run leaves the graph exactly as it found it with dataset "parallel bail"
✓ a green complete run leaves the graph exactly as it found it with dataset "parallel one process"
✓ a green complete run leaves the graph exactly as it found it with dataset "parallel more processes than files"
✓ --tia --no-tia is a plain run that still refreshes what it executed with dataset "sequential"
✓ --tia --no-tia is a plain run that still refreshes what it executed with dataset "parallel"
✓ --fresh on a partial run neither purges nor prunes with dataset "sequential"
✓ --fresh on a partial run neither purges nor prunes with dataset "parallel"
✓ a second green run on a feature branch writes nothing at all with dataset "sequential"
✓ a second green run on a feature branch writes nothing at all with dataset "parallel"
✓ a graph whose recorded commit is gone is re-anchored, not warned about forever with dataset "sequential"
✓ a graph whose recorded commit is gone is re-anchored, not warned about forever with dataset "parallel"
PASS Tests\Features\Ticket
✓ it may be associated with an ticket #1, #2
✓ nested → it may be associated with an ticket #1, #4, #5, #6, #3
@@ -1990,6 +1786,7 @@
✓ it shows the correct description for long texts with newlines
✓ it shows the correct description for arrays with many elements
✓ it shows the correct description of datasets with html
✓ it does not treat a two element dataset of class names as a callable
PASS Tests\Unit\Expectations\OppositeExpectation
✓ it throw expectation failed exception with string argument
@@ -2132,6 +1929,14 @@
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
✓ still detects --tia when an integer argument is present
PASS Tests\Unit\Plugins\Tia\JsModuleGraph
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "exact"
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong leaf"
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong parent"
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "absent"
✓ it resolves the pages directory with the casing it has on disk
✓ it fingerprints a project whose js tree holds a directory it cannot open
PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock
✓ it applies only to package-lock.json
✓ it returns null for contents that are not an npm lockfile
@@ -2154,6 +1959,10 @@
✓ it records assertions against the same per-dataset key
✓ it leaves a test without a dataset keyed by class and method
PASS Tests\Unit\Plugins\Tia\Storage
✓ it uses a project-relative configured directory
✓ it uses an absolute configured directory
PASS Tests\Unit\Plugins\Tia\TableExtractor
✓ fromSql() → it extracts tables from plain DML
✓ fromSql() → it extracts tables from joins
@@ -2247,11 +2056,18 @@
✓ it builds the expected alias map from a vite config with ('laravel-plugin-default')
✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin')
✓ it builds the expected alias map from a vite config with ('no-config')
✓ it accepts a page directory candidate only when it matches the casing on disk with ('exact')
✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong leaf')
✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong parent')
✓ it accepts a page directory candidate only when it matches the casing on disk with ('absent')
PASS Tests\Unit\Preset
✓ preset invalid name
✓ preset → myFramework
PASS Tests\Unit\Restarters\PcovRestarter
✓ it preserves the active memory limit in the restarted process
PASS Tests\Unit\Support\Arr
✓ last → it should return false for an empty arary
✓ last → it should return the last element for an array with a single element
@@ -2435,4 +2251,4 @@
✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value')
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 34 skipped, 1760 passed (3983 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1596 passed (3481 assertions)
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
beforeEach(function (): void {
expect(true)->toBeTrue();
})->assignee('nunomaduro');
+11
View File
@@ -239,3 +239,14 @@ test('intercept can add new parameters to the expectation', function (): void {
expect('Foo')->toBe('foo', $ignoreCase);
});
test('named arguments reach the pipes of an expectation', function () use ($state): void {
$char = new Char('A');
$state->reset();
expect($char)->toBe(expected: new Char('A'))
->and($state)
->runCount->toHaveKey('char', 1)
->appliedCount->toHaveKey('char', 1);
});
+76 -11
View File
@@ -16,7 +16,7 @@ beforeEach(function (): void {
});
test('pass', function (): void {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
expect($this->snapshotable)->toMatchSnapshot();
});
@@ -39,7 +39,7 @@ test('pass using pipes', function (): void {
});
test('pass with `__toString`', function (): void {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
$object = new class($this->snapshotable)
{
@@ -55,7 +55,7 @@ test('pass with `__toString`', function (): void {
});
test('pass with `toString`', function (): void {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
$object = new class($this->snapshotable)
{
@@ -71,8 +71,8 @@ test('pass with `toString`', function (): void {
});
test('pass with dataset', function ($data): void {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
$filename = TestSuite::getInstance()->snapshots->current()->path();
expect($filename)->toStartWith('tests/.pest/snapshots/')
->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value___.snap')
@@ -81,8 +81,8 @@ test('pass with dataset', function ($data): void {
describe('within describe', function (): void {
test('pass with dataset', function ($data): void {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
$filename = TestSuite::getInstance()->snapshots->current()->path();
expect($filename)->toStartWith('tests/.pest/snapshots/')
->toEndWith('pass_with_dataset_with_data_set____my_datas_set_value___.snap')
@@ -91,7 +91,7 @@ describe('within describe', function (): void {
})->with(['my-datas-set-value']);
test('pass with `toArray`', function (): void {
TestSuite::getInstance()->snapshots->save(json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT));
TestSuite::getInstance()->snapshots->current()->write(json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT));
$object = new class($this->snapshotable)
{
@@ -109,7 +109,7 @@ test('pass with `toArray`', function (): void {
});
test('pass with array', function (): void {
TestSuite::getInstance()->snapshots->save(json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT));
TestSuite::getInstance()->snapshots->current()->write(json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT));
expect([
'key' => $this->snapshotable,
@@ -117,7 +117,7 @@ test('pass with array', function (): void {
});
test('pass with `toSnapshot`', function (): void {
TestSuite::getInstance()->snapshots->save(json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT));
TestSuite::getInstance()->snapshots->current()->write(json_encode(['key' => $this->snapshotable], JSON_PRETTY_PRINT));
$object = new class($this->snapshotable)
{
@@ -135,7 +135,7 @@ test('pass with `toSnapshot`', function (): void {
});
test('not failures', function (): void {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
TestSuite::getInstance()->snapshots->current()->write($this->snapshotable);
expect($this->snapshotable)->not->toMatchSnapshot();
})->throws(ExpectationFailedException::class);
@@ -161,3 +161,68 @@ test('multiple snapshot expectations with repeat', function (): void {
expect('foo bar 1')->toMatchSnapshot()
->and('foo bar 2')->toMatchSnapshot();
})->repeat(10);
test('pass with named snapshot', function (): void {
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->named('header')->write($this->snapshotable);
expect($snapshots->named('header')->path())
->toStartWith('tests/.pest/snapshots/')
->toEndWith('pass_with_named_snapshot__header.snap')
->and($this->snapshotable)->toMatchSnapshot(as: 'header');
});
test('named snapshots do not depend on the order they are asserted in', function (): void {
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->named('first')->write('foo bar 1');
$snapshots->named('second')->write('foo bar 2');
expect('foo bar 2')->toMatchSnapshot(as: 'second')
->and('foo bar 1')->toMatchSnapshot(as: 'first');
});
test('named snapshots do not consume the ordinal of the unnamed ones', function (): void {
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->current()->write('foo bar 1');
$snapshots->named('named')->write('foo bar 2');
expect('foo bar 1')->toMatchSnapshot()
->and('foo bar 2')->toMatchSnapshot(as: 'named')
->and($snapshots->current()->path())->toEndWith('named_snapshots_do_not_consume_the_ordinal_of_the_unnamed_ones.snap');
});
test('named snapshots require a name', function (): void {
TestSuite::getInstance()->snapshots->named('_');
})->throws(InvalidArgumentException::class, 'The snapshot name must contain at least one alphanumeric character.');
test('ordinal snapshots start over once forgotten', function (): void {
$snapshots = TestSuite::getInstance()->snapshots;
$first = $snapshots->next()->path();
expect($snapshots->next()->path())->toEndWith('ordinal_snapshots_start_over_once_forgotten__2.snap');
$snapshots->forget();
expect($snapshots->next()->path())->toBe($first);
});
test('ordinal snapshots start over on every repetition', function (): void {
$snapshots = TestSuite::getInstance()->snapshots;
$first = $snapshots->next()->path();
expect($first)->toContain('ordinal_snapshots_start_over_on_every_repetition')
->and($snapshots->next()->path())->toBe(str_replace('.snap', '__2.snap', $first));
})->repeat(3);
test('snapshots of a repeated test are recorded per repetition', function (int $iteration): void {
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->current()->write('foo bar');
$snapshots->named('named')->write('foo bar');
expect('foo bar')->toMatchSnapshot()
->and($snapshots->current()->path())->toEndWith("_with_data_set___{$iteration}__.snap")
->and('foo bar')->toMatchSnapshot(as: 'named')
->and($snapshots->named('named')->path())->toEndWith("_with_data_set___{$iteration}____named.snap");
})->repeat(3);
+36
View File
@@ -1,5 +1,6 @@
<?php
use Pest\TestSuite;
use Symfony\Component\Process\Process;
it('passes on first try', function (): void {
@@ -231,6 +232,41 @@ it('does not leak mock objects between retries', function (): void {
expect($mock->count())->toBe(1);
})->flaky(tries: 3);
it('resolves the same snapshot ordinals on every retry', function (): void {
$file = sys_get_temp_dir().'/pest_flaky_snapshot_ordinals';
$count = file_exists($file) ? (int) file_get_contents($file) : 0;
file_put_contents($file, (string) ++$count);
$snapshots = TestSuite::getInstance()->snapshots;
expect($snapshots->next()->path())->toEndWith('resolves_the_same_snapshot_ordinals_on_every_retry.snap')
->and($snapshots->next()->path())->toEndWith('resolves_the_same_snapshot_ordinals_on_every_retry__2.snap');
if ($count < 3) {
throw new Exception('Flaky snapshot ordinals');
}
@unlink($file);
})->flaky(tries: 3);
it('matches the same snapshot on every retry', function (): void {
$file = sys_get_temp_dir().'/pest_flaky_snapshot_match';
$count = file_exists($file) ? (int) file_get_contents($file) : 0;
file_put_contents($file, (string) ++$count);
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->current()->write('the same on every attempt');
expect('the same on every attempt')->toMatchSnapshot()
->and($snapshots->current()->path())->toEndWith('matches_the_same_snapshot_on_every_retry.snap');
if ($count < 2) {
throw new Exception('Flaky snapshot match');
}
@unlink($file);
})->flaky(tries: 3);
it('does not stop retrying when snapshot changes are absent', function (): void {
$file = sys_get_temp_dir().'/pest_flaky_no_snapshot';
$count = file_exists($file) ? (int) file_get_contents($file) : 0;
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
beforeEach(function (): void {
expect(true)->toBeTrue();
})->issue(1);
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
beforeEach(function (): void {
expect(true)->toBeTrue();
})->pr(1);
+24
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
test('once', function (): void {
expect(true)->toBeTrue();
})->repeat(times: 1);
@@ -44,6 +46,28 @@ test('multiple times with repeat iterator with multiple dataset', function (stri
->toBeGreaterThan(0);
})->repeat(times: 2)->with(['a', 'b', 'c'], ['d', 'e', 'f']);
test('multiple times with named dataset arguments', function (string $name, string $email): void {
expect($name)
->toBeIn(['Taylor', 'Nuno'])
->and($email)
->toContain('@');
})->repeat(times: 2)->with([
['name' => 'Taylor', 'email' => 'taylor@laravel.com'],
['name' => 'Nuno', 'email' => 'enunomaduro@gmail.com'],
]);
test('multiple times with named dataset arguments and repeat iterator', function (string $name, string $email, int $iteration): void {
expect($name)
->toBeIn(['Taylor', 'Nuno'])
->and($email)
->toContain('@')
->and($iteration)
->toBeIn([1, 2]);
})->repeat(times: 2)->with([
['name' => 'Taylor', 'email' => 'taylor@laravel.com'],
['name' => 'Nuno', 'email' => 'enunomaduro@gmail.com'],
]);
describe('describe blocks', function (): void {
test('multiple times', function (): void {
expect(true)->toBeTrue();
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
it('allows access to the underlying expectNotToPerformAssertions method', function (): void {
$this->expectNotToPerformAssertions();
+1
View File
@@ -56,6 +56,7 @@ it('does not run user hooks when replaying cached skipped and incomplete results
'PARATEST' => 0,
'PAO_DISABLE' => '1',
'HOME' => $home,
'CI_DEFAULT_BRANCH' => $branch,
],
);
@@ -33,6 +33,34 @@ test('a complete run prunes a deleted test', function (array $arguments): void {
->and($delta->structureMoved())->toBeFalse($delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a complete run stays complete when the last test is skipped from a hook', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->write('tests/Unit/GreeterTest.php', <<<'PHP'
<?php
declare(strict_types=1);
use Fixture\App\Greeter;
beforeEach(function (): void {
test()->markTestSkipped('not today');
});
test('greets a person', function (): void {
expect((new Greeter)->greet('Nuno'))->toBe('Hello, Nuno!');
});
PHP);
$result = $project->pest(...$arguments);
$delta = $project->delta();
expect($result->tally())->toContain('1 skipped')
->and($delta->removed())->toBe(1, $delta->summary())
->and($delta->added())->toBe(0, $delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a complete run records nothing for a test file the graph does not know', function (): void {
$project = Project::make('master');
$project->seed('master');
+27
View File
@@ -358,3 +358,30 @@ test('a graph whose recorded commit is gone is re-anchored, not warned about for
->and($second->replayed())->toBe(Project::TOTAL_TESTS, $second->describe())
->and($delta->writtenCount())->toBe(0, $delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
test('a full suite run without a coverage driver clears the tree it could not refresh', function (array $arguments): void {
$project = Project::make('master');
$project->seed('master');
$project->mutateGraph(function (array $graph): array {
$graph['baselines']['master']['tree'] = ['app/Calculator.php' => 'deadbeefdeadbeefdeadbeefdeadbeef'];
return $graph;
});
$environment = ['XDEBUG_MODE' => 'off'];
$first = $project->pestWithEnvironment($project->path(), $environment, '--tia', ...$arguments);
expect($first->exitCode)->toBe(0, $first->describe())
->and($first->output)->toContain('no coverage driver is available')
->and($first->tally())->toContain(Project::TOTAL_TESTS.' passed');
$project->snapshot();
$second = $project->pestWithEnvironment($project->path(), $environment, '--tia', ...$arguments);
$delta = $project->delta();
expect($second->output)->not->toContain('no coverage driver is available')
->and($second->replayed())->toBe(Project::TOTAL_TESTS, $second->describe())
->and($delta->writtenCount())->toBe(0, $delta->summary());
})->with(Project::SEQUENTIAL_AND_PARALLEL)->skipOnWindows();
+2
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
beforeEach(function (): void {
expect(true)->toBeTrue();
})->ticket(1);
+24
View File
@@ -109,3 +109,27 @@ it('shows the correct description of datasets with html', function (): void {
expect($descriptions[0])->toBe('(\'<div class="flex items-center"></div>\')');
});
it('does not treat a two element dataset of class names as a callable', function (): void {
$datasets = DatasetsRepository::resolve([
[
MagicCallDataset::class,
AnotherMagicCallDataset::class,
],
], __FILE__);
expect(array_values($datasets))->toBe([
[MagicCallDataset::class],
[AnotherMagicCallDataset::class],
]);
});
class MagicCallDataset
{
public static function __callStatic(string $name, array $arguments): void
{
throw new RuntimeException('This dataset should not be called.');
}
}
class AnotherMagicCallDataset {}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use Pest\Plugins\Tia\JsModuleGraph;
function tiaJsModuleGraphCall(string $method, mixed ...$arguments): mixed
{
return new ReflectionMethod(JsModuleGraph::class, $method)->invoke(null, ...$arguments);
}
function tiaJsModuleGraphProject(): string
{
$root = sys_get_temp_dir().'/pest-tia-js-module-graph-'.bin2hex(random_bytes(6));
mkdir($root.'/resources/js/pages', 0755, true);
file_put_contents($root.'/vite.config.ts', "export default {}\n");
file_put_contents($root.'/resources/js/pages/Dashboard.vue', "<template>ok</template>\n");
return $root;
}
function tiaJsModuleGraphRemove(string $path): void
{
if (! is_dir($path)) {
@unlink($path);
return;
}
@chmod($path, 0755);
$entries = @scandir($path);
foreach ($entries === false ? [] : $entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
tiaJsModuleGraphRemove($path.'/'.$entry);
}
@rmdir($path);
}
beforeEach(function (): void {
$this->projectRoot = tiaJsModuleGraphProject();
});
afterEach(function (): void {
tiaJsModuleGraphRemove($this->projectRoot);
});
it('accepts a page directory candidate only when every segment matches the casing on disk', function (string $candidate, bool $expected): void {
expect(tiaJsModuleGraphCall('matchesDiskCasing', $this->projectRoot, $candidate))->toBe($expected);
})->with([
'exact' => ['resources/js/pages', true],
'wrong leaf' => ['resources/js/Pages', false],
'wrong parent' => ['Resources/js/pages', false],
'absent' => ['assets/js/pages', false],
]);
it('resolves the pages directory with the casing it has on disk', function (): void {
$expected = $this->projectRoot
.DIRECTORY_SEPARATOR.'resources'
.DIRECTORY_SEPARATOR.'js'
.DIRECTORY_SEPARATOR.'pages';
expect(tiaJsModuleGraphCall('firstExistingPagesDir', $this->projectRoot))->toBe($expected);
});
it('fingerprints a project whose js tree holds a directory it cannot open', function (): void {
$locked = $this->projectRoot.'/resources/js/locked';
mkdir($locked, 0755, true);
file_put_contents($locked.'/Secret.vue', "<template>ok</template>\n");
chmod($locked, 0000);
if (is_readable($locked)) {
$this->markTestSkipped('the current user reads directories regardless of their mode.');
}
expect(tiaJsModuleGraphCall('fingerprint', $this->projectRoot))->toBeString();
});
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use Pest\Plugins\Tia\Bootstrapper;
use Pest\Plugins\Tia\Configuration;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\FileState;
use Pest\Plugins\Tia\Storage;
use Pest\Support\Container;
use Pest\TestSuite;
afterEach(function (): void {
Storage::useDirectory(null);
});
it('uses a project-relative configured directory', function (): void {
$container = new Container;
$testSuite = new TestSuite(sys_get_temp_dir(), 'tests');
$container->add(TestSuite::class, $testSuite);
(new Configuration)->directory('.pest/tia');
new Bootstrapper($container)->boot();
$state = $container->get(State::class);
if (! $state instanceof FileState) {
throw new RuntimeException('Expected the TIA state to use file storage.');
}
expect(Storage::tempDir('/project'))->toBe('/project'.DIRECTORY_SEPARATOR.'.pest/tia')
->and($state->pathFor('graph.json'))->toBe($testSuite->rootPath.DIRECTORY_SEPARATOR.'.pest/tia/graph.json');
});
it('uses an absolute configured directory', function (): void {
(new Configuration)->directory('/tmp/pest-tia');
expect(Storage::tempDir('/project'))->toBe('/tmp/pest-tia');
});
+63
View File
@@ -357,6 +357,63 @@ function tiaViteAliasResults(): array
return $cache = ['roots' => $roots, 'aliases' => $aliases];
}
function tiaViteCasingFixtures(): array
{
return [
'exact' => ['resources/js/pages', true],
'wrong leaf' => ['resources/js/Pages', false],
'wrong parent' => ['Resources/js/pages', false],
'absent' => ['assets/js/pages', false],
];
}
function tiaViteCasingResults(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$root = sys_get_temp_dir().'/pest-tia-vite-casing-'.bin2hex(random_bytes(6));
mkdir($root.'/resources/js/pages', 0755, true);
file_put_contents($root.'/resources/js/pages/Dashboard.vue', "<template>ok</template>\n");
$helper = str_replace('\\', '/', tiaViteHelperPath());
$normalized = str_replace('\\', '/', $root);
$payload = [];
foreach (tiaViteCasingFixtures() as $name => [$candidate]) {
$payload[] = ['name' => $name, 'candidate' => $candidate];
}
$inputFile = tempnam(sys_get_temp_dir(), 'tia-vite-casing-');
file_put_contents($inputFile, json_encode($payload));
$input = str_replace('\\', '/', $inputFile);
$script = <<<JS
import { matchesDiskCasing } from '{$helper}'
import { readFileSync } from 'node:fs'
const cases = JSON.parse(readFileSync('{$input}', 'utf8'))
const out = {}
for (const c of cases) out[c.name] = await matchesDiskCasing('{$normalized}', c.candidate)
process.stdout.write(JSON.stringify(out))
JS;
$process = new Process(['node', '--input-type=module', '-e', $script]);
$process->mustRun();
$results = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
@unlink($inputFile);
@unlink($root.'/resources/js/pages/Dashboard.vue');
@rmdir($root.'/resources/js/pages');
@rmdir($root.'/resources/js');
@rmdir($root.'/resources');
@rmdir($root);
return $cache = $results;
}
beforeEach(function (): void {
if ((new ExecutableFinder)->find('node') === null) {
$this->markTestSkipped('node is not available.');
@@ -409,3 +466,9 @@ it('builds the expected alias map from a vite config', function (string $name):
expect($results['aliases'][$name])->toEqual($expected);
})->with(array_keys(tiaViteAliasFixtures()));
it('accepts a page directory candidate only when it matches the casing on disk', function (string $name): void {
[, $expected] = tiaViteCasingFixtures()[$name];
expect(tiaViteCasingResults()[$name])->toBe($expected);
})->with(array_keys(tiaViteCasingFixtures()));
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Pest\Restarters\PcovRestarter;
use Symfony\Component\Process\Process;
it('preserves the active memory limit in the restarted process', function (): void {
$originalMemoryLimit = ini_get('memory_limit');
try {
ini_set('memory_limit', '1234M');
$command = new ReflectionMethod(PcovRestarter::class, 'command')
->invoke(new PcovRestarter, __DIR__, ['-r', 'fwrite(STDOUT, (string) ini_get("memory_limit"));']);
assert(is_array($command));
$process = new Process($command);
$process->mustRun();
expect($process->getOutput())->toBe('1234M');
} finally {
ini_set('memory_limit', (string) $originalMemoryLimit);
}
});
+1 -13
View File
@@ -20,19 +20,7 @@ test('parallel', function () use ($run): void {
$output = $run('--exclude-group=integration');
$output = implode("\n", array_slice(explode("\n", (string) $output), -10));
if (getenv('REBUILD_SNAPSHOTS')) {
preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches);
$file = file_get_contents(__FILE__);
$file = preg_replace(
'/\$expected = \'.*?\';/',
"\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1543 passed (3345 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1543 passed (3345 assertions)';
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1578 passed (3426 assertions)';
expect($output)
->toContain("Tests: {$expected}")
+2 -2
View File
@@ -12,9 +12,9 @@ test('visual snapshot of test suite on success', function (): void {
$output = function () use ($testsPath): ?string {
$process = (new Process(
['php', '-d', 'memory_limit=-1', 'bin/pest'],
['php', '-d', 'memory_limit=-1', 'bin/pest', '--exclude-group=tia'],
dirname($testsPath),
['EXCLUDE' => 'integration', '--exclude-group' => 'integration', 'REBUILD_SNAPSHOTS' => false, 'PARATEST' => 0, 'COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'PAO_DISABLE' => '1'],
['EXCLUDE' => 'integration', 'REBUILD_SNAPSHOTS' => false, 'PARATEST' => 0, 'COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true', 'PAO_DISABLE' => '1'],
));
$process->setTimeout(300.0);