mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
49 Commits
e90e4f70fc
..
v5.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 20a5aacaca | |||
| eac05bcee9 | |||
| b5b2ba7d7f | |||
| 627092ecfc | |||
| 2692613f09 | |||
| e763a87b42 | |||
| 4a65054f83 | |||
| b8c2265d66 | |||
| 39cbdcd52b | |||
| aba2b79609 | |||
| 1af74a215c | |||
| cfe89d20ac | |||
| 2c7cc1dc87 | |||
| 9b36f44b3e | |||
| 2e58918201 | |||
| a8d4770c0b | |||
| 208f447a10 | |||
| 6b7ca13660 | |||
| bf7c6b3134 | |||
| 617226ca01 | |||
| 375b92e93b | |||
| e0e00ca748 | |||
| d70ecb873e | |||
| 638932ddb5 | |||
| ac48185736 | |||
| 2e4f8ad56f | |||
| 8c554a81e2 | |||
| 42d9b777bf | |||
| eb88513baf | |||
| 7b6415ccd0 | |||
| a5d726b678 | |||
| 93223caa56 | |||
| d0572ce138 | |||
| b0d1fc589d | |||
| 5a4f124199 | |||
| c3ceb7d0c7 | |||
| 16d1208344 | |||
| 1cebc84c2d | |||
| 1f79660add | |||
| 37bdf60a1c | |||
| cc7acfe485 | |||
| 7bfb2a6185 | |||
| ca56ca5216 | |||
| b49ba062d5 | |||
| 09699847a2 | |||
| 545d0c2784 | |||
| b795af3b1b | |||
| a40cc6bc0e | |||
| 4d3d0105b7 |
@@ -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
|
||||
|
||||
@@ -14,3 +14,6 @@ coverage.xml
|
||||
.vscode/
|
||||
.STREAM.md
|
||||
|
||||
|
||||
/.claude/skills/
|
||||
/.agents/skills/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,18 +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
|
||||
```
|
||||
@AGENTS.md
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
# TIA write-tier conformance — plan
|
||||
|
||||
Baseline sweep: **147 cases, 144 PASS / 2 FAIL / 1 SKIP** against `dev-fix/tia-filtered` (`db70017`)
|
||||
on `playground/laravel` with pcov enabled.
|
||||
|
||||
This file records (1) what is left to fix, and (2) the full case matrix with the target
|
||||
outcome for every row, so the sweep can be re-run as a conformance check after the fixes land.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — What's left
|
||||
|
||||
### 1. `--parallel` silently under-records `edges` (G12, I5) — **high**
|
||||
|
||||
Any `--tia --parallel` complete run writes `files=4` where sequential writes `files=25`. Only
|
||||
`app/**` and blade views survive; every `config/`, `routes/`, `bootstrap/` edge and every test
|
||||
self-edge is lost. A `routes/web.php` edit that sequential TIA catches, parallel TIA reports as
|
||||
`No affected tests found`.
|
||||
|
||||
Root cause — `src/Restarters/PcovRestarter.php:33`:
|
||||
|
||||
```php
|
||||
if (! Tia::isEnabledForRun($arguments)) {
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`Tia::isEnabledForRun()` (`src/Plugins/Tia.php:275-288`) returns true only for `--tia` in argv or
|
||||
the `PEST_TIA` env flag. A paratest worker's argv carries neither, so workers never re-exec with
|
||||
`-d pcov.directory=<projectRoot>` and record under pcov's empty default scope.
|
||||
|
||||
Proven both directions:
|
||||
- `PEST_PCOV_RESTARTER_RESTARTED=1 pest --tia` (sequential, restart suppressed) reproduces
|
||||
`files=4` with edge sets identical to the parallel graph.
|
||||
- `PEST_TIA=1 pest --parallel` restores the full `files=25` graph. **Working workaround today.**
|
||||
|
||||
Coverage config is irrelevant: widening `phpunit.xml` `<include>` to `app|config|routes|bootstrap|tests`
|
||||
still yields `files=4`.
|
||||
|
||||
Sticky, not just per-run: `pest --tia --filtered --parallel` re-records only the affected test
|
||||
through a worker and strips that entry's edges from an otherwise-healthy sequential graph, so one
|
||||
parallel run degrades a good graph incrementally.
|
||||
|
||||
**Fix direction:** propagate the TIA-enabled decision into workers (e.g. set `PEST_TIA` in the
|
||||
worker env, or have the parent stamp a recording global the restarter also consults) so
|
||||
`PcovRestarter` restarts them. Then assert `edges` equivalence between sequential and parallel.
|
||||
|
||||
### 2. Deleted test file with a cached failure wedges `--filtered` forever (I9) — **high**
|
||||
|
||||
Expected a WARN and a full-suite fallback. Actual: `--filtered` selects the phantom file, runs
|
||||
nothing, exits **0 green**, on every subsequent invocation.
|
||||
|
||||
Root cause — `src/Plugins/Tia/Graph.php:1523-1525`:
|
||||
|
||||
```php
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
}
|
||||
```
|
||||
|
||||
`relative()` falls back to the raw path when `realpath()` fails, so a deleted-but-in-project test
|
||||
file is never "unlocated", `Graph::hasUnlocatedTestsToRerun()` never fires, and the WARN branch at
|
||||
`src/Plugins/Tia.php:982-986` is unreachable for the deletion case. `pruneMissingTests()` only runs
|
||||
under `--fresh`, so nothing clears it.
|
||||
|
||||
**Fix direction:** distinguish "path could not be resolved" from "path resolved outside the root" in
|
||||
`relative()`, or have `hasUnlocatedTestsToRerun()` stat the file directly.
|
||||
|
||||
### 3. `--coverage-text` does not disable filtered mode (I8) — **low**
|
||||
|
||||
`coverageReportActive()` (`src/Plugins/Tia.php:1705-1710`) reads only Pest's own `--coverage`, so
|
||||
raw PHPUnit coverage flags (`--coverage-text`, `--coverage-html`, `--coverage-clover`, …) leave
|
||||
filtered mode on. `Tia::COVERAGE_FLAGS` at `:113-115` already enumerates them — that list is not
|
||||
consulted here.
|
||||
|
||||
### 4. `--coverage` narrows edges like parallel does — **medium, needs triage**
|
||||
|
||||
`pest --tia --coverage` prints `fresh graph (recording coverage baseline)` even when a graph
|
||||
exists, and writes `Feature/ExampleTest` with 2 files instead of 16, dropping self-edges. Same
|
||||
observable shape as G12, likely a different mechanism (piggyback collector scoped by
|
||||
`phpunit.xml <include>` rather than the pcov-restarted recorder). Theme: **edges silently narrow to
|
||||
`app/**` whenever recording does not go through the pcov-restarted TIA recorder.** Confirm whether
|
||||
these are one fix or two.
|
||||
|
||||
### 5. Smaller items
|
||||
|
||||
| Item | Where | Note |
|
||||
|---|---|---|
|
||||
| Warning/deprecation recorded as `status=0` | write path | Codes `6` and `4` appear unreachable; risky/skipped/incomplete record faithfully |
|
||||
| Complete non-TIA runs write edge-less results | `src/Plugins/Tia.php:1645`, `:638` | Guard is `! $complete && ! knowsTest()`; complete bypasses it and `markKnownTestFiles` stays false. Inert — replay guards on the same predicate at `:360` — but inflates `n` |
|
||||
| SIGINT never reaches the suite | `PcovRestarter` re-exec | Parent ignores it; the child holds PHPUnit's handler. CI `timeout`/Ctrl-C will not stop a run |
|
||||
| Structural drift never announced sequentially | `src/Plugins/Tia.php:1178-1181` | `enterRecordMode()` prints a bare `Running in TIA mode.`; `renderFreshGraph()` (`fresh graph (composer.lock changed)`) only runs under `--parallel` or coverage piggyback |
|
||||
| Replay clobbers cached `time` | write path | `0.058` → `0.001`; timing data degrades toward zero across runs |
|
||||
| `pest --parallel` without `--tia` writes nothing | G3, G6 | Sequential `pest --filter=…` does record. Parallel CI contributes nothing to the cached-failure replay path |
|
||||
| `--tia --parallel --filter`/`--shard` record nothing | G2, G8 | More conservative than the results-only contract requires |
|
||||
| `--min=50` without `--coverage` is a silent no-op | — | — |
|
||||
| `pest --repeat=2` is not a Pest option | J11 | Case unrunnable as written; drop it or add the option |
|
||||
|
||||
### 6. Test-harness gaps to close before re-running
|
||||
|
||||
- The playground has no `UsesClass`, `->note()`, `->flaky()`, `->issue()`, `->pr()`, `->ticket()` or
|
||||
`->assignee()` annotations, so **C19, C23–C28, C37–C39 matched zero tests** and their
|
||||
graph-invariant assertions proved nothing. Add annotated fixtures to make those rows load-bearing.
|
||||
- Use **sentinel patching** (rewrite every cached entry to `time=9.999 assertions=42`, then see which
|
||||
entries get overwritten) as the discriminator. It is the only way to tell "wrote identical values"
|
||||
from "wrote nothing". A canary test absent from `edges` is unreliable under `--filter` because it
|
||||
never matches the filter and so never runs.
|
||||
- `--tia` on a clean green tree can never cache a failure (unchanged tests replay rather than
|
||||
execute); seeding one requires `--fresh` or an env-driven flaky fixture.
|
||||
- Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Full case matrix (target outcomes)
|
||||
|
||||
Tiers: **COMPLETE** may change everything · **RESULTS-ONLY** (RO) may change only
|
||||
`baselines[<branch>].results` for tests that ran, and must never remove an entry, add a result for a
|
||||
test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||
**HARD-SUPPRESSED** may change nothing.
|
||||
|
||||
Status column: `PASS` = conforming today · `FIX` = must pass once the item above lands ·
|
||||
`SKIP` = case not runnable as written.
|
||||
|
||||
### A — Setup & sanity
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| A1 | `composer show pestphp/pest` | version `dev-fix/tia-filtered` | PASS |
|
||||
| A2 | `pest --baseline` | prints an existing dir; exit 0 | PASS |
|
||||
| A3 | delete graph, `pest --tia` | graph.json created; one `edges` key per test file; one result per test | PASS |
|
||||
| A4 | `git status` after reset ritual | clean | PASS |
|
||||
| A5 | `extension_loaded("pcov")` | `true` | PASS |
|
||||
| A6 | delete graph, plain `pest` | no graph created | PASS |
|
||||
| A7 | delete graph, `pest --filter=adds` | no graph created | PASS |
|
||||
| A8 | two consecutive `pest --tia` | second replays everything; `sha` unchanged | PASS |
|
||||
|
||||
### B — COMPLETE runs still write
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| B1 | `pest --tia` (clean) | replays; graph written; `sha` = `git rev-parse HEAD` | PASS |
|
||||
| B2 | `pest --no-tia` | results refreshed; prune applied; `sha`/`tree`/`edges` unchanged | PASS |
|
||||
| B3 | `pest` (plain) | same as B2 | PASS |
|
||||
| B4 | `pest --tia --fresh` | graph purged and rebuilt; `edges` rebuilt; `files` repopulated | PASS |
|
||||
| B5 | `pest --bail`, all green | COMPLETE — graph written, prune applied | PASS |
|
||||
| B6 | edit `Calculator.php`, `pest --tia` | `CalculatorTest` re-runs, others replay; `tree` updated | PASS |
|
||||
| B7 | delete `trio three`, `pest --tia` | its result pruned; `trio one`/`two` remain | PASS |
|
||||
| B8 | delete `GreeterTest.php`, `pest --tia` | result and edges survive (missing-file prune is `--fresh`-only) | PASS |
|
||||
| B9 | B8 then `pest --tia --fresh` | entry gone; `files` drops `Greeter.php` too | PASS |
|
||||
| B10 | `pest --tia` twice | `time` differs; statuses stable | PASS |
|
||||
| B11 | add a test file, `pest --tia` | new `edges` key + new result appear | PASS |
|
||||
| B12 | `pest --tia --coverage` | completes; graph written | PASS |
|
||||
|
||||
### C — Selection narrowing → RESULTS-ONLY
|
||||
|
||||
All rows: RO invariants hold. "Notice" = `TIA does not apply to partial runs — running the selected tests directly.`
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| C1 | `pest --filter="adds numbers"` | RO; no notice | PASS |
|
||||
| C2 | `pest --tia --filter="adds numbers"` | RO; notice | PASS |
|
||||
| C3 | `pest --filter="trio one"` | RO; `trio two`/`three` byte-identical | PASS |
|
||||
| C4 | `pest --filter="trio"` | RO; all three update; nothing else does | PASS |
|
||||
| C5 | `pest --exclude-filter="Feature"` | RO; no notice; no prune | PASS |
|
||||
| C6 | `pest --tia --exclude-filter="Feature"` | RO; notice | PASS |
|
||||
| C7 | `pest --group=smoke` | RO; no notice | PASS |
|
||||
| C8 | `pest --tia --group=smoke` | RO; notice | PASS |
|
||||
| C9 | `pest --exclude-group=smoke` | RO; no notice; no prune | PASS |
|
||||
| C10 | `pest --tia --exclude-group=smoke` | RO; notice | PASS |
|
||||
| C11 | `pest tests/Unit` | RO; no notice; Feature entry not pruned | PASS |
|
||||
| C12 | `pest --tia tests/Unit` | RO; notice | PASS |
|
||||
| C13 | `pest tests/Unit/TrioTest.php` | RO; no notice | PASS |
|
||||
| C14 | `pest --tia tests/Unit/TrioTest.php` | RO; notice | PASS |
|
||||
| C15 | `pest --testsuite=Unit` | RO; no notice | PASS |
|
||||
| C16 | `pest --tia --testsuite=Unit` | RO; notice | PASS |
|
||||
| C17 | `pest --tia --exclude-testsuite=Feature` | RO; notice | PASS |
|
||||
| C18 | `pest --tia --covers='App\Services\Calculator'` | RO; notice | PASS |
|
||||
| C19 | `pest --tia --uses='App\Services\Calculator'` | RO; notice | PASS (vacuous — needs fixture) |
|
||||
| C20 | `pest --tia --test-suffix=Test.php` | RO even though all tests run; `edges`/`files`/`n` unchanged | PASS |
|
||||
| C21 | `pest --dirty` with an uncommitted test edit | RO; no notice | PASS |
|
||||
| C22 | `pest --tia --dirty` | RO; notice | PASS |
|
||||
| C23 | `pest --tia --todos` | RO; notice | PASS (vacuous) |
|
||||
| C24 | `pest --tia --notes` | RO; notice | PASS (vacuous) |
|
||||
| C25 | `pest --tia --flaky` | RO; notice | PASS (vacuous) |
|
||||
| C26 | `pest --tia --issue=123` | RO; notice | PASS (vacuous) |
|
||||
| C27 | `pest --tia --pr=1` | RO; notice | PASS (vacuous) |
|
||||
| C28 | `pest --tia --pull-request=1` | RO; notice | PASS (vacuous) |
|
||||
| C29 | `pest --tia --shard=1/2` | RO; notice; no prune | PASS |
|
||||
| C30 | `pest --tia --shard=2/2` | RO; notice; C29+C30 covers the suite; neither prunes | PASS |
|
||||
| C31 | `->only()` on `trio one`, `pest --tia` | RO; no notice; siblings untouched | PASS |
|
||||
| C32 | `->only()` on `trio one`, plain `pest` | RO; no notice | PASS |
|
||||
| C33 | `pest --tia --filtered --filter=adds` | RO; notice; filtered yields to narrowing | PASS |
|
||||
| C34 | `PEST_TIA=1 pest --filter=adds` | RO; notice | PASS |
|
||||
| C35 | `PEST_TIA_FILTERED=1 pest --filter=adds` | RO; notice (regression guard) | PASS |
|
||||
| C36 | `pest --filtered --filter=adds` | RO; notice | PASS |
|
||||
| C37 | `pest --tia --ticket=X` | RO; notice. `--ticket` **is** a real option (`src/Plugins/Snapshot.php:83`) — it narrows legitimately | PASS (vacuous) |
|
||||
| C38 | `pest --tia --assignee=X` | RO; notice (`src/Plugins/Snapshot.php:81`) | PASS (vacuous) |
|
||||
| C39 | `pest --tia --todo` | RO; notice; matches nothing | PASS (vacuous) |
|
||||
|
||||
### D — Truncation → RESULTS-ONLY
|
||||
|
||||
D1–D5, D11–D13, D16 precondition: `trio one` broken. D6–D10 run against a green suite so the `--stop-on-*` trigger is the only narrowing.
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| D1 | `pest --bail` | RO; `trio one` = `status=7`; siblings survive unchanged | PASS |
|
||||
| D2 | `pest --retry` | RO; siblings survive | PASS |
|
||||
| D3 | `pest --stop-on-failure` | RO | PASS |
|
||||
| D4 | `pest --stop-on-defect` | RO | PASS |
|
||||
| D5 | `pest --stop-on-error` (no error occurs) | COMPLETE — nothing stopped it | PASS |
|
||||
| D6 | `pest --stop-on-warning` | RO when it fires. Warning stored as `status=0`, not `6` | PASS |
|
||||
| D7 | `pest --stop-on-risky --disallow-test-output` | RO; `status=5` | PASS |
|
||||
| D8 | `pest --stop-on-skipped` | RO; `status=1` | PASS |
|
||||
| D9 | `pest --stop-on-incomplete` | RO; `status=2` | PASS |
|
||||
| D10 | `pest --stop-on-deprecation` | RO. Deprecation stored as `status=0`, not `4` | PASS |
|
||||
| D11 | `pest --tia --bail` | RO | PASS |
|
||||
| D12 | `pest --bail --filter=trio` | RO (narrowed *and* truncated) | PASS |
|
||||
| D13 | `stopOnFailure="true"` in `phpunit.xml`, plain `pest` | RO — caught via `stoppedEarly()`, not flag matching | PASS |
|
||||
| D14 | D13 config, green suite | COMPLETE | PASS |
|
||||
| D15 | `pest --bail`, last test in run order fails | RO — over-conservative but safe; nothing was skipped | PASS |
|
||||
| D16 | `pest --tia --filtered --bail`, broken affected test | RO | PASS |
|
||||
|
||||
### E — Result merge semantics
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| E1 | cached `flaky`=7, `FLAKY_OK=1 pest --filter=flaky` | flips to `status=0`; all other results byte-identical | PASS |
|
||||
| E2 | cached `flaky`=0, `pest --filter=flaky` (env unset) | flips to `status=7` | PASS |
|
||||
| E3 | after E1, `pest --tia --filtered` clean | `No affected tests found`; zero graph delta | PASS |
|
||||
| E4 | after E2, `pest --tia --filtered` | re-runs, `from 1 previously unsuccessful test` | PASS |
|
||||
| E5 | any partial run | `assertions` and `time` update for the test that ran | PASS |
|
||||
| E6 | any partial run | `message` of untouched tests unchanged | PASS |
|
||||
| E7 | two sequential partial runs on different tests | each updates only its own entry; both persist | PASS |
|
||||
| E8 | `pest --filter="trio one"` | siblings keep exact `status`/`time`/`assertions`/`message` | PASS |
|
||||
| E9 | partial run of a `->skip()`ed test | `status=1` | PASS |
|
||||
| E10 | partial run of a `->todo()` test | `status=1`, `assertions=0`, `message="__TODO__"` | PASS |
|
||||
| E11 | partial run of a risky test | `status=5` | PASS |
|
||||
| E12 | any partial run | `fingerprint` byte-identical | PASS |
|
||||
| E13 | dataset test, `--filter` matching one row | that row updates; other rows survive (the old prune bug) | PASS |
|
||||
| E14 | dataset test, full `--tia` after deleting a row | the deleted row is pruned | PASS |
|
||||
|
||||
### F — Guard rails
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| F1 | test absent from `edges`, `pest --filter="brand new"` | no result recorded | PASS |
|
||||
| F2 | complete `pest --tia` first, then the same filter | result is recorded | PASS |
|
||||
| F3 | `pest --mutate --path=app/Services --covered-only` | statuses/`edges`/`sha`/`tree`/`files` identical; only `time` may change | PASS |
|
||||
| F4 | `pest --mutate --parallel --path=… --covered-only` | same (observed: zero delta) | PASS |
|
||||
| F5 | weakened test so a mutant survives, `pest --mutate` | no status change in the baseline | PASS |
|
||||
| F6 | `pest --mutate` with the graph deleted | no graph created | PASS |
|
||||
| F7 | mutation subprocess env | carries `PEST_MUTATION_TESTING`; `--mutate` stripped from argv | PASS |
|
||||
| F8 | grep baseline after `--mutate` | no mutation-flavoured messages | PASS |
|
||||
|
||||
### G — Parallel
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| G1 | `pest --tia --parallel` (clean) | COMPLETE — graph written | PASS |
|
||||
| G2 | `pest --tia --parallel --filter=adds` | RO | PASS |
|
||||
| G3 | `pest --parallel --bail`, `trio one` broken | RO; siblings survive | PASS |
|
||||
| G4 | `pest --tia --parallel --bail` | RO | PASS |
|
||||
| G5 | `pest --tia --filtered --parallel` after a source edit | narrows to affected; writes | PASS (but see G12 — it strips edges) |
|
||||
| G6 | `pest --tia --parallel` | worker results reach the parent baseline | PASS |
|
||||
| G7 | `->only()` + `pest --tia --parallel` | RO | PASS |
|
||||
| G8 | `pest --tia --parallel --shard=1/2` | RO | PASS |
|
||||
| G9 | broken test in one worker, `pest --parallel --bail` | whole run is RO, not just that worker's slice | PASS |
|
||||
| G10 | `pest --parallel --retry` | `InvalidOption`; graph untouched | PASS |
|
||||
| G11 | `pest --tia --parallel --fresh` | graph rebuilt | PASS |
|
||||
| G12 | `edges` after `pest --tia` vs `pest --tia --parallel --fresh` | **equivalent edge sets** (`files=25` both) | **FIX (item 1)** |
|
||||
|
||||
### H — Baseline key / branch resolution
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| H1 | `pest --tia` on `master` | only a `master` key | PASS |
|
||||
| H2 | plain `pest` on `master` | writes `master`, not `main` | PASS |
|
||||
| H3 | `pest --bail` truncated on `master` | no `main` key minted | PASS |
|
||||
| H4 | `pest --filter=adds` on `master` | no `main` key minted | PASS |
|
||||
| H5 | `git branch -m main`, full `pest --tia` | single `main` key | PASS |
|
||||
| H6 | `git checkout -b feature/x`, `pest --tia` | `feature/x` key appears; reads fall back to the existing baseline | PASS |
|
||||
| H7 | detached HEAD, `pest --tia` | falls back to the `main` key for both reads and writes; no `HEAD` key | PASS |
|
||||
| H8 | after every C and D case | no baseline key other than the real branch | PASS |
|
||||
| H9 | non-git dir, `pest --tia` | `MissingDependency` — `The feature "Tia mode" requires "git".` | PASS |
|
||||
| H10 | non-git dir, plain `pest` | runs normally; no baseline dir created | PASS |
|
||||
|
||||
### I — Filtered mode
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| I1 | clean + green, `pest --tia --filtered` | `No affected tests found`; zero graph delta | PASS |
|
||||
| I2 | edit `Calculator.php` | only `CalculatorTest` runs | PASS |
|
||||
| I3 | cached failure, clean tree | re-runs it, `from 1 previously unsuccessful test` | PASS |
|
||||
| I4 | I2 with sentinels | `tree` updated; exactly one entry rewritten; all others retained | PASS |
|
||||
| I5 | `pest --tia --filtered --parallel` | same narrowing as I2 **and `edges` unchanged** | **FIX (item 1)** |
|
||||
| I6 | `PEST_TIA_FILTERED=1 pest --tia` | identical delta to I4 | PASS |
|
||||
| I7 | `pest --tia --filtered tests/Unit` | explicit path wins; filtered off; RO; notice | PASS |
|
||||
| I8 | `pest --tia --filtered --coverage-text` | filtered mode disabled by an active coverage report | **FIX (item 3)** |
|
||||
| I9 | cached failure whose test file was deleted | WARN `could not be located on disk`; falls back to the full suite with replay | **FIX (item 2)** |
|
||||
| I10 | `pest --tia --filtered` with no baseline yet | records a baseline instead of filtering | PASS |
|
||||
| I11 | edit a Blade view a Feature test renders | the Feature test is selected | PASS |
|
||||
| I12 | edit `composer.lock` | fingerprint drift → full rebuild (and the drift reason should be printed — item 5) | PASS |
|
||||
|
||||
### J — Interactions & regressions
|
||||
|
||||
| # | Case | Target outcome | Status |
|
||||
|---|---|---|---|
|
||||
| J1 | `pest --tia --fresh --filter=adds` | graph **not** purged; RO | PASS |
|
||||
| J2 | `pest --tia --refetch --filter=adds` | refetch not performed; RO | PASS |
|
||||
| J3 | `pest --no-tia --filter=adds` | RO — narrowing wins over `--no-tia`; no notice | PASS |
|
||||
| J4 | `pest --tia --no-tia` | TIA disabled; COMPLETE | PASS |
|
||||
| J5 | `<groups><exclude><group>integration</group></exclude></groups>`, `pest --tia` | COMPLETE, no notice — an always-in-force filter applies to baseline runs too | PASS |
|
||||
| J6 | `pest --tia --compact` | COMPLETE | PASS |
|
||||
| J7 | `pest --tia -v` | COMPLETE | PASS |
|
||||
| J8 | `pest --tia --profile` | COMPLETE | PASS |
|
||||
| J9 | `pest --tia --order-by=random` | COMPLETE | PASS |
|
||||
| J10 | `pest --tia --random-order-seed=1234` | COMPLETE | PASS |
|
||||
| J11 | `pest --tia --repeat=2` | COMPLETE — repetition is not narrowing | **SKIP** — `--repeat` is not a Pest option |
|
||||
| J12 | `pest --filter=adds` twice | only the executed test's `time` differs; rest byte-identical | PASS |
|
||||
| J13 | `pest --tia --min=50` | COMPLETE | PASS |
|
||||
| J14 | SIGINT mid-suite | RO — an interrupted run is truncated | PASS (signal must go to the re-exec'd child — item 5) |
|
||||
| J15 | delete the graph, `pest --tia --filtered` | records a baseline rather than erroring | PASS |
|
||||
| J16 | corrupt `graph.json`, `pest --tia` | recovers by rebuilding; no crash | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Re-run procedure
|
||||
|
||||
```bash
|
||||
cd /Users/nunomaduro/Work/projects/playground/laravel
|
||||
GRAPH="$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)/graph.json"
|
||||
|
||||
# per case
|
||||
git checkout -q . && git clean -qfd tests app
|
||||
rm -rf "$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)"
|
||||
PAO_DISABLE=1 ./vendor/bin/pest --tia >/dev/null 2>&1
|
||||
# sentinel-patch every result to time=9.999 assertions=42, snapshot, run the case, diff
|
||||
```
|
||||
|
||||
Every pest invocation must be prefixed with `PAO_DISABLE=1` (the app has `laravel/pao`, which emits
|
||||
JSON when it detects an agent). The shell is zsh — build commands with arrays or `eval`; unquoted
|
||||
`$args` does not word-split.
|
||||
@@ -1,455 +0,0 @@
|
||||
# TIA default-branch fallback — phase three
|
||||
|
||||
## Your task
|
||||
|
||||
Fix [pestphp/pest#1823](https://github.com/pestphp/pest/issues/1823) — TIA's cached results never
|
||||
hit for repos whose default branch is not literally `main` — then re-run the **whole** conformance
|
||||
matrix against the playground app, plus the new section **L** that covers the fix.
|
||||
|
||||
Work in this order, and **stop where the plan says stop**:
|
||||
|
||||
1. **Part 1** — read the diagnosis. It is already measured; do not re-derive it, but do re-confirm
|
||||
the two reproductions in Part 1.3 take ~2 minutes and prove your environment is sane.
|
||||
2. **Part 2** — implement the fix in the `pestphp/pest` repo. **Do not commit. Do not touch the
|
||||
playground's `vendor/`.**
|
||||
3. **HARD STOP → Part 3.** Report the diff to Nuno and wait. He validates, commits, and syncs it
|
||||
into the playground himself. You must not proceed until he confirms.
|
||||
4. **Part 4** — verify the sync landed, then run section **L** (new) and the full phase-two matrix
|
||||
(**A–K**, all 156 rows) against the playground.
|
||||
5. **Part 5** — report in the given format.
|
||||
|
||||
Per `CLAUDE.md`: **do not write new `pestphp/pest` unit tests and do not run `composer test`.** Make
|
||||
the change, report it, and ask whether repo tests should be added. The section-L rows are *playground
|
||||
invocations*, not repo tests — those are the deliverable and are always in scope.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — The diagnosis (already measured; commit `bfd5b756`)
|
||||
|
||||
### 1.1 Root cause — two independent hardcoded `'main'` literals
|
||||
|
||||
**(a) The read fallback.** `src/Plugins/Tia/Graph.php` — seven methods default the fallback to the
|
||||
literal `'main'`:
|
||||
|
||||
| line | method |
|
||||
|---|---|
|
||||
| 579 | `recordedAtSha(string $branch, string $fallbackBranch = 'main')` |
|
||||
| 614 | `getAssertions(…, string $fallbackBranch = 'main')` |
|
||||
| 625 | `getTime(…, string $fallbackBranch = 'main')` |
|
||||
| 636 | `getResult(…, string $fallbackBranch = 'main')` |
|
||||
| 663 | `testFilesToRerun(string $branch, string $fallbackBranch = 'main')` |
|
||||
| 700 | `hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main')` |
|
||||
| 811 | `lastRunTree(string $branch, string $fallbackBranch = 'main')` |
|
||||
|
||||
They all funnel into `Graph::baselineFor()` (line 819), which *does* implement a real cross-branch
|
||||
fallback:
|
||||
|
||||
```php
|
||||
if (isset($this->baselines[$branch])) return $this->baselines[$branch];
|
||||
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) return $this->baselines[$fallbackBranch];
|
||||
return ['sha' => null, 'tree' => [], 'results' => []];
|
||||
```
|
||||
|
||||
The mechanism is deliberate. The problem is that **no caller ever passes `$fallbackBranch`** — all
|
||||
nine call sites in `src/Plugins/Tia.php` (lines 419, 429, 432, 785, 857, 1026, 1031, 1048, 1056) pass
|
||||
only `$this->branch`. So on a `master`-named repo the second branch of `baselineFor()` can never
|
||||
fire, and the whole mechanism is dead code.
|
||||
|
||||
`grep -rniE 'defaultBranch|symbolic-ref|init\.defaultBranch|origin/HEAD' src/` returns **nothing** —
|
||||
no default-branch resolution exists anywhere.
|
||||
|
||||
**(b) The detached-HEAD default.** `src/Plugins/Tia.php:206` declares `private string $branch = 'main';`
|
||||
and `ChangedFiles::currentBranch()` (`ChangedFiles.php:208`) returns `null` for detached HEAD. So on
|
||||
detached HEAD `$this->branch` stays the literal `'main'` and is used for **both reads and writes** —
|
||||
minting a baseline key for a branch that does not exist. This is a *write*-side bug and a separate fix
|
||||
from (a).
|
||||
|
||||
### 1.2 Not a regression
|
||||
|
||||
`git log -S"fallbackBranch = 'main'"` bottoms out at `c7e32f5d feat(tia): continues to work on poc`.
|
||||
This is original PoC code, untouched by phase one. Phase one's change 1 modified
|
||||
`hasUnlocatedTestsToRerun()`'s file-existence check — one of the seven methods — without going near
|
||||
the fallback. Do not report it as a phase-one regression.
|
||||
|
||||
No test anywhere exercises the mechanism: `tests/Unit/Plugins/Tia/Graph.php` uses `'main'` as the
|
||||
*actual* branch name, so those assertions pass whether or not the fallback exists. A branch-name
|
||||
mismatch is never tested.
|
||||
|
||||
### 1.3 The two reproductions (re-confirm these before you start)
|
||||
|
||||
Both on the playground, `master`-named default branch, zero local changes:
|
||||
|
||||
```
|
||||
default = master default = main
|
||||
1. record on default → full run (cold) → full run (cold)
|
||||
2. 1st run on feature-x → 25 UNCACHED → 25 replayed ← (a)
|
||||
3. 2nd run on feature-x → 25 replayed → 25 replayed
|
||||
4. back on default → 25 replayed → 25 replayed
|
||||
5. 1st run on feature-y → 25 UNCACHED → 25 replayed
|
||||
```
|
||||
|
||||
```
|
||||
master-only graph, then `git checkout --detach`, then `pest --tia`:
|
||||
→ 25 uncached, and keys become [master,main] ← (b) spurious key
|
||||
```
|
||||
|
||||
The cost is **one full run per new branch, forever**, with no output explaining why — the only clue
|
||||
is the `N uncached` count; the headline is just `─ Experimental TIA mode enabled.`
|
||||
|
||||
### 1.4 Correction to phase two
|
||||
|
||||
Phase two reported **H6 and H7 as passing. Both were false passes.** They ran after H5, which had
|
||||
renamed `master`→`main` and left a `main` key in the graph, so the hardcoded fallback resolved by
|
||||
accident. Re-measured against a clean `master`-only graph, both fail. Section L replaces them as the
|
||||
load-bearing rows; H6/H7 must be re-run **from a cold graph** this time (see Part 4.2).
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — The fix to implement
|
||||
|
||||
### 2.1 Recommended design
|
||||
|
||||
**Step 1 — add a non-throwing resolver** to `src/Plugins/Tia/ChangedFiles.php`, next to
|
||||
`currentBranch()`:
|
||||
|
||||
```php
|
||||
public function defaultBranch(): ?string
|
||||
```
|
||||
|
||||
Resolution order, each step failing soft to the next:
|
||||
|
||||
1. `git symbolic-ref --short refs/remotes/origin/HEAD` → strip a leading `origin/`
|
||||
2. `git config --get init.defaultBranch`
|
||||
3. `null`
|
||||
|
||||
Unlike `currentBranch()`, this must **never throw** `MissingDependency` — it is advisory. Return
|
||||
`null` on any non-zero exit or empty output.
|
||||
|
||||
**Step 2 — add a config surface.** `src/Plugins/Tia/Configuration.php` already exposes `always()`,
|
||||
`locally()`, `filtered()`, `baselined()`, `watch()`. Add:
|
||||
|
||||
```php
|
||||
public function defaultBranch(string $branch): self
|
||||
```
|
||||
|
||||
so `pest()->tia()->defaultBranch('master')` works in `tests/Pest.php`. Explicit config **always
|
||||
wins** over autodetection — that is the escape hatch when `origin/HEAD` is unset.
|
||||
|
||||
**Step 3 — resolve once, in `Tia.php`.** The read path is hot (`getResult()` is called per test at
|
||||
line 419), so resolution must not shell out per call. Resolve alongside `$this->branch` at
|
||||
`Tia.php:1910`, under the existing `$branchResolved` guard:
|
||||
|
||||
```php
|
||||
$this->fallbackBranch = $configuredDefaultBranch
|
||||
?? $changedFiles->defaultBranch()
|
||||
?? 'main';
|
||||
```
|
||||
|
||||
**Step 4 — thread it into `Graph`.** Prefer a `Graph`-level property over editing nine call sites:
|
||||
add `Graph::setFallbackBranch(string $branch)`, change the seven signatures to
|
||||
`?string $fallbackBranch = null`, and resolve inside each with
|
||||
`$fallbackBranch ??= $this->fallbackBranch;`. `baselineFor()` itself needs no change. This keeps the
|
||||
public signatures backward-compatible and minimises blast radius.
|
||||
|
||||
**Step 5 — fix the detached-HEAD write.** `Tia.php:206`'s `= 'main'` default must become the
|
||||
resolved default branch, so detached HEAD stops minting a phantom key.
|
||||
|
||||
### 2.2 Invariants the fix must not break
|
||||
|
||||
These are all covered by existing matrix rows — the fix is wrong if any of them moves:
|
||||
|
||||
- **Read-only.** The fallback must affect *reads* only. Writes go through `ensureBaseline($branch)`
|
||||
and must keep using the real current branch. Otherwise H1–H4/H8 ("no baseline key other than the
|
||||
real branch") break.
|
||||
- **H9** — a non-git dir with `--tia` must still raise
|
||||
`MissingDependency: The feature "Tia mode" requires "git".` Adding a soft resolver must not
|
||||
swallow that.
|
||||
- **H10** — plain `pest` in a non-git dir must still run and create no baseline dir.
|
||||
- **A2/A3** — cold-graph recording unchanged.
|
||||
- **I1/E3** — clean+green `--tia --filtered` must still be a true zero-delta run.
|
||||
- Filtered mode reads `testFilesToRerun()` and `hasUnlocatedTestsToRerun()`, so the fallback must
|
||||
reach those two as well, not just `getResult()`.
|
||||
|
||||
### 2.3 Decisions for Nuno (raise these at the Part 3 stop)
|
||||
|
||||
- **D1** — Consult `origin/HEAD` at all? It requires `git remote set-head` and is absent in many CI
|
||||
checkouts and all remote-less repos. Config + `init.defaultBranch` only is simpler but helps fewer
|
||||
people out of the box. *Recommendation: keep it, first in the chain, since it fails soft.*
|
||||
- **D2** — Single-key heuristic: if the graph holds exactly one baseline key, use it as the fallback?
|
||||
Fixes the issue with zero git calls, but is implicit and surprising when several keys exist.
|
||||
*Recommendation: no.*
|
||||
- **D3** — Should detached HEAD write a baseline at all, or be read-only? Current behaviour mints a
|
||||
key. *Recommendation: read-only.*
|
||||
- **D4** — Should `pest()->tia()->defaultBranch()` validate that the branch exists, or accept any
|
||||
string? *Recommendation: accept any string; a nonexistent name degrades to a full run, which is
|
||||
safe.*
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — HARD STOP
|
||||
|
||||
When the code is written:
|
||||
|
||||
1. Show Nuno the diff (`git -C /Users/nunomaduro/Work/projects/pestphp/pest diff`) and a one-paragraph
|
||||
summary of each file's change.
|
||||
2. Answer/raise the D1–D4 decisions.
|
||||
3. State explicitly that you have **not** committed and have **not** synced `vendor/`.
|
||||
4. Ask whether repo unit tests should be added (per `CLAUDE.md`), describing the tests you have in
|
||||
mind — do not write them yet.
|
||||
5. **Wait.** Nuno commits and applies the change to the playground.
|
||||
|
||||
**Never sync the playground's `vendor/` yourself.** `vendor/pestphp/pest` there is a dist copy, not a
|
||||
symlink (composer installed `dev-fix/tia-filtered as 5.2.0`), so pest-repo edits do not reach it. Say
|
||||
what is stale and wait. This applies to before/after contrasts too.
|
||||
|
||||
Once he confirms, verify the sync actually landed before measuring anything:
|
||||
|
||||
```bash
|
||||
V=/Users/nunomaduro/Work/projects/playground/laravel/vendor/pestphp/pest
|
||||
grep -c 'defaultBranch' "$V/src/Plugins/Tia/ChangedFiles.php" # must be ≥ 1
|
||||
for f in src/Plugins/Tia.php src/Plugins/Tia/Graph.php src/Plugins/Tia/ChangedFiles.php \
|
||||
src/Plugins/Tia/Configuration.php; do
|
||||
diff -q "/Users/nunomaduro/Work/projects/pestphp/pest/$f" "$V/$f" >/dev/null \
|
||||
&& echo "SAME $f" || echo "STALE $f"
|
||||
done
|
||||
```
|
||||
|
||||
If anything reports `STALE`, stop and tell him. State in your final report which pest commit produced
|
||||
the playground numbers.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Measurement
|
||||
|
||||
### 4.1 Environment and traps
|
||||
|
||||
**Playground:** `/Users/nunomaduro/Work/projects/playground/laravel` (branch `master`).
|
||||
|
||||
1. **Every** pest invocation needs `PAO_DISABLE=1` — the app has `laravel/pao`, which emits JSON when
|
||||
it detects an agent.
|
||||
2. **Pin the interpreter to `php85`.** The playground requires PHP `>= 8.4.1` *and* pcov. Locally only
|
||||
`php85` (8.5.8) has both — `php84` (8.4.23) has no pcov, and the bare Herd `php` shim has been
|
||||
observed drifting to 8.3.32 mid-session, which kills every run in
|
||||
`vendor/composer/platform_check.php`. Also put a `php` → `php85` symlink first on `PATH`: the
|
||||
`--shard` list-tests probe spawns a subprocess via bare `php`, not `PHP_BINARY`.
|
||||
3. **Never run `git checkout .` or `git checkout -- composer.lock` in the playground.** It has four
|
||||
pre-existing user-modified files — `AGENTS.md`, `CLAUDE.md`, `composer.json`, `composer.lock`. Scope
|
||||
resets to `git checkout -- tests app` / `git clean -fd tests app`. For `phpunit.xml` and
|
||||
`composer.lock`, copy aside and copy back, verifying with `shasum`.
|
||||
4. **zsh does not word-split unquoted parameters.** A `$PEST` string containing a space becomes one
|
||||
command name. Route every invocation through `eval` (the `pest()` helper below does this).
|
||||
5. Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||
6. **The sentinel technique must not falsify `assertions` on zero-assertion tests.** A
|
||||
risky/skipped/incomplete status is *derived* from "performed no assertions", so patching those to
|
||||
`42` rewrites the status on replay and destroys the discriminator — it shows up as a phantom
|
||||
`status 5→0` defect. The `sentinel.php` below only patches `assertions` where it is already
|
||||
non-zero. With that, a full replay gives a clean `rewritten=0`.
|
||||
7. Restore branch state after every L row. Several rows rename or detach; leaving a stray `main` key
|
||||
in the graph is exactly what produced phase two's false H6/H7 passes.
|
||||
|
||||
### 4.2 Reference numbers
|
||||
|
||||
The playground already carries the phase-two fixtures at commit `fb2e77e` — **do not rebuild them.**
|
||||
A healthy sequential `--tia` graph is:
|
||||
|
||||
> **`files=27`, 10 edge keys (one per test file), self-edge on all 10, `n=25` results.**
|
||||
|
||||
`sha` will differ once Nuno commits the vendor sync — re-derive it once and use it throughout. The
|
||||
`files`/`edges`/`n` numbers hold as long as no test fixture changes. Suite shape: 10 test files, 25
|
||||
tests, including six deliberate status fixtures (skipped, todo, incomplete, risky, warning,
|
||||
deprecation), a 3-row dataset, a `smoke` group, an env-driven flaky test (green unless
|
||||
`FLAKY_FAIL=1`), and the annotation set (`covers`/`note`/`flaky`/`issue`/`pr`/`ticket`/`assignee`).
|
||||
|
||||
**Re-run H6 and H7 from a cold graph** (`rm -rf` the graph dir, record on `master` only, *then*
|
||||
branch/detach). Their phase-two results are void.
|
||||
|
||||
### 4.3 Harness
|
||||
|
||||
Write these to your scratchpad. `$SP` is your own scratchpad dir.
|
||||
|
||||
<details>
|
||||
<summary><code>lib.sh</code></summary>
|
||||
|
||||
```bash
|
||||
#!/bin/zsh
|
||||
export PAO_DISABLE=1
|
||||
PG=/Users/nunomaduro/Work/projects/playground/laravel
|
||||
SP="<your scratchpad>"
|
||||
PHPBIN="php85"
|
||||
PEST="$PHPBIN $PG/vendor/bin/pest"
|
||||
cd "$PG" || exit 1
|
||||
pest() { eval "$PEST $*"; } # zsh: no word-splitting, must eval
|
||||
GRAPHDIR="$(pest --baseline)"
|
||||
GRAPH="$GRAPHDIR/graph.json"
|
||||
mkdir -p "$SP/bin" && ln -sf "$(command -v php85)" "$SP/bin/php"
|
||||
export PATH="$SP/bin:$PATH" # --shard spawns bare `php`
|
||||
reset_tree() { git checkout -- tests app 2>/dev/null; git clean -qfd tests app 2>/dev/null; }
|
||||
seed() { rm -rf "$GRAPHDIR"; pest --tia >/dev/null 2>&1; $PHPBIN "$SP/sentinel.php" "$GRAPH" >/dev/null; cp "$GRAPH" "$SP/before.json"; }
|
||||
snap() { cp "$GRAPH" "$SP/before.json"; }
|
||||
delta() { $PHPBIN "$SP/cmp.php" "$SP/before.json" "$GRAPH"; }
|
||||
keys() { $PHPBIN -r '$g=json_decode(file_get_contents($argv[1]),true);echo "[".implode(",",array_keys($g["baselines"]??[]))."]";' "$GRAPH"; }
|
||||
tally() { sed -E $'s/\x1b\\[[0-9;]*[a-zA-Z]//g' <"$SP/out.txt" | grep -E 'Tests:' | sed -E 's/^ +//;s/Tests: +//'; }
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>sentinel.php</code> — the write discriminator</summary>
|
||||
|
||||
```php
|
||||
<?php // sentinel.php <graph.json>
|
||||
$p = $argv[1];
|
||||
$g = json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR);
|
||||
$n = 0;
|
||||
foreach ($g['baselines'] ?? [] as $br => $b) {
|
||||
foreach (array_keys($b['results'] ?? []) as $id) {
|
||||
$g['baselines'][$br]['results'][$id]['time'] = 9.999;
|
||||
// Only falsify a non-zero assertion count: risky/skipped/incomplete are
|
||||
// DERIVED from "performed no assertions", so patching those to 42 would
|
||||
// rewrite the status on replay and destroy the discriminator.
|
||||
if ((int) ($b['results'][$id]['assertions'] ?? 0) > 0) {
|
||||
$g['baselines'][$br]['results'][$id]['assertions'] = 42;
|
||||
}
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
file_put_contents($p, json_encode($g, JSON_THROW_ON_ERROR));
|
||||
echo "sentinelled $n results\n";
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>oneline.php</code> — one compact tier verdict per row</summary>
|
||||
|
||||
```php
|
||||
<?php // oneline.php <before.json> <after.json>
|
||||
function load(string $p): ?array {
|
||||
return is_file($p) ? json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR) : null;
|
||||
}
|
||||
$a = load($argv[1]); $b = load($argv[2]);
|
||||
if ($a === null || $b === null) { echo 'GRAPH '.($b === null ? 'DELETED' : 'CREATED'); exit; }
|
||||
function edgeSets(array $g): array {
|
||||
$out = [];
|
||||
foreach ($g['edges'] ?? [] as $t => $ids) { $s = array_map(fn($i) => $g['files'][$i] ?? "?$i", (array) $ids); sort($s); $out[$t] = $s; }
|
||||
ksort($out); return $out;
|
||||
}
|
||||
$moved = [];
|
||||
if (edgeSets($a) !== edgeSets($b)) $moved[] = 'edges';
|
||||
if (($a['files'] ?? []) !== ($b['files'] ?? [])) $moved[] = 'files';
|
||||
if (($a['fingerprint'] ?? null) !== ($b['fingerprint'] ?? null)) $moved[] = 'fingerprint';
|
||||
$brA = array_keys($a['baselines'] ?? []); $brB = array_keys($b['baselines'] ?? []);
|
||||
if ($brA !== $brB) $moved[] = 'branchkeys('.implode('|', $brA).'->'.implode('|', $brB).')';
|
||||
$add = $rem = $wr = 0; $shaMoved = $treeMoved = false;
|
||||
foreach ($brB as $br) {
|
||||
$ra = $a['baselines'][$br]['results'] ?? []; $rb = $b['baselines'][$br]['results'] ?? [];
|
||||
if (($a['baselines'][$br]['sha'] ?? null) !== ($b['baselines'][$br]['sha'] ?? null)) $shaMoved = true;
|
||||
if (($a['baselines'][$br]['tree'] ?? null) !== ($b['baselines'][$br]['tree'] ?? null)) $treeMoved = true;
|
||||
$add += count(array_diff(array_keys($rb), array_keys($ra)));
|
||||
$rem += count(array_diff(array_keys($ra), array_keys($rb)));
|
||||
foreach ($ra as $id => $x) {
|
||||
if (! isset($rb[$id])) continue;
|
||||
foreach (['status','time','assertions','message'] as $f) {
|
||||
if (($x[$f] ?? null) !== ($rb[$id][$f] ?? null)) { $wr++; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
$n = 0; foreach ($brB as $br) $n = max($n, count($b['baselines'][$br]['results'] ?? []));
|
||||
printf('n=%d w=%-2d +%d -%d %s%s%s', $n, $wr, $add, $rem,
|
||||
$moved === [] ? 'struct:ok' : 'STRUCT:'.implode(',', $moved),
|
||||
$shaMoved ? ' sha:CHANGED' : '', $treeMoved ? ' tree:chg' : '');
|
||||
```
|
||||
</details>
|
||||
|
||||
`cmp.php` (verbose per-entry version of the same) and `summarise.php` / `edgediff.php` are in
|
||||
`PLAN_PHASE_TWO.md` §"Graph summariser" — reuse them for drill-downs. Reading the verdict:
|
||||
|
||||
- `w=` — entries actually **written**. Under sentinel patching this is the only reliable way to tell
|
||||
"wrote identical values" from "wrote nothing". A full replay must give `w=0`.
|
||||
- `struct:ok` + `+0 -0` — no prune, no edges/files/fingerprint movement. Required by RESULTS-ONLY.
|
||||
- `STRUCT:branchkeys(...)` — a new baseline key appeared. For section L this is the headline signal.
|
||||
|
||||
### 4.4 Section L — new rows for this fix
|
||||
|
||||
Tiers, unchanged from phase two: **COMPLETE** may change everything · **RESULTS-ONLY (RO)** may
|
||||
change only `baselines[<branch>].results` for tests that ran, and must never remove an entry, add a
|
||||
result for a test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||
**HARD-SUPPRESSED** may change nothing.
|
||||
|
||||
Every L row starts from a **cold graph recorded on the named default branch only** — verify
|
||||
`keys=[<default>]` before branching. Restore branch state afterwards.
|
||||
|
||||
| # | Case | Target outcome |
|
||||
|---|---|---|
|
||||
| L1 | default `master`, record, `git switch -c feature-x`, `pest --tia`, zero changes | **all 25 replayed** (`w=0`), not `25 uncached`. The headline fix. |
|
||||
| L2 | as L1 but default `main` | still all replayed — regression guard, this already worked |
|
||||
| L3 | default `trunk`, then `develop` | replayed for both; the fix must not special-case two names |
|
||||
| L4 | L1, then a *second* new branch `feature-y` | replayed too — the toll must not return per branch |
|
||||
| L5 | L1 then edit `app/Services/Calculator.php` on `feature-x` | narrows to the 2 affected files (`CalculatorTest` + `AnnotationsTest`, which `covers` it); the other 17 replay |
|
||||
| L6 | L1 with `--tia --filtered` | filtered mode reads the fallback too (`testFilesToRerun`, `hasUnlocatedTestsToRerun`) → `No affected tests found`, zero delta |
|
||||
| L7 | L1 with `--tia --parallel` | fallback works in workers as well as the parent |
|
||||
| L8 | L1, then `pest --tia` twice on `feature-x` | idempotent; second run also `w=0` |
|
||||
| L9 | detached HEAD on a `master`-only graph | replays, and **no `main` key minted** — `keys` stays `[master]`. Bug (b). |
|
||||
| L10 | `pest()->tia()->defaultBranch('master')` in `tests/Pest.php`, repo default renamed away | config wins over autodetect |
|
||||
| L11 | config set to a nonexistent branch (`defaultBranch('nope')`) | degrades to a full run; no crash, no phantom key |
|
||||
| L12 | no remote at all (`git remote remove origin` if present) | still resolves (via `init.defaultBranch`) or degrades safely — must not throw |
|
||||
| L13 | branch name with a slash (`feature/x/y`) | replayed; no key-splitting bugs |
|
||||
| L14 | L1, then confirm writes | `feature-x` gets its **own** key; the `master` key is **not** written to (fallback is read-only) |
|
||||
| L15 | git worktree on a new branch (the issue's scenario) | replays from the default-branch baseline |
|
||||
| L16 | non-git dir, `pest --tia` | still `MissingDependency: The feature "Tia mode" requires "git".` — the soft resolver must not swallow it |
|
||||
| L17 | non-git dir, plain `pest` | runs normally; no baseline dir created |
|
||||
| L18 | count `git` subprocesses during one `--tia` run | default-branch resolution is cached, not one call per test. Probe by shimming `git` on `PATH` to a logging wrapper. |
|
||||
|
||||
L10–L11 need a `tests/Pest.php` edit — that file is tracked and **outside** the `tests app` reset
|
||||
scope in practice (it lives in `tests/`, so `git checkout -- tests` does restore it; verify with
|
||||
`git status` after).
|
||||
|
||||
For L16/L17, build a throwaway non-git project — and note the trap that burned phase two: a
|
||||
**symlinked** `vendor` makes Pest resolve the project root back to the playground (identical baseline
|
||||
hash), silently invalidating the test. Use a hardlinked copy:
|
||||
|
||||
```bash
|
||||
NG="$SP/nogit"; rm -rf "$NG"; mkdir -p "$NG"
|
||||
cp -R composer.json composer.lock phpunit.xml artisan tests app bootstrap config routes resources storage "$NG/"
|
||||
[ -f .env ] && cp .env "$NG/"
|
||||
cp -Rl vendor "$NG/vendor" || cp -R vendor "$NG/vendor"
|
||||
```
|
||||
|
||||
Confirm the baseline path differs (`nogit-<hash>`, not `laravel-4a455a95622ac0ec`), and delete both
|
||||
the temp project and its `~/.pest/tia/nogit-*` dir afterwards.
|
||||
|
||||
### 4.5 Re-run the phase-two matrix (A–K, 156 rows)
|
||||
|
||||
Re-run every row of `PLAN_PHASE_TWO.md` Part 2 against the fixed build. No row's status is trusted
|
||||
until re-measured — the fix touches `Graph`'s read path, which nearly every row exercises. Sections
|
||||
**A, B, H, I** are the load-bearing ones here (H is branch-key resolution; I is filtered mode; both
|
||||
consume the changed methods directly). **H6 and H7 must be re-derived from a cold graph** (Part 1.4).
|
||||
|
||||
Most rows batch cheaply — phase two ran C1–C20 in one call at roughly one line of output each. Use
|
||||
`oneline.php` for the sweep and `cmp.php` only to drill into anomalies.
|
||||
|
||||
### 4.6 Known pre-existing failures — do not report as regressions
|
||||
|
||||
| item | status |
|
||||
|---|---|
|
||||
| **G4 / G4b** — parallel replay clobbers cached `time` on all non-executed tests. `mergeWorkerReplayPartials()` takes `$result['time']` verbatim at `Tia.php:1451`, never routing through `resultTime()` as the sequential sites (1681, 1762) do. Assertions survive because workers replay those themselves. | Pre-existing (pre-fix had no preservation at all), **out of scope for phase three**. Report it as still-present; do not fix it unless Nuno asks. |
|
||||
| **C19** — `--tia --uses=…` cannot be fixtured. TIA hard-errors on PHPUnit classes (`EnsureTiaIsRunningPestTestsOnly`), and Pest has no chainable `->uses()`. | **Expected behaviour per Nuno.** Verify the tier (`w=0`, RO, notice) and move on. Not a defect. |
|
||||
| **J11** — `--repeat` is not a Pest option (`Unknown option "--repeat"`). | **Don't care per Nuno.** Mark SKIP. |
|
||||
| **J10** — `--random-order-seed` alone exits 1 with a WARN. Identical without `--tia`. | Pre-existing Pest behaviour, unrelated. Tier still holds. |
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Reporting
|
||||
|
||||
Per row: **tier respected (yes/no)**, the **graph delta** under sentinel patching, and for any failure
|
||||
the **pre-fix contrast** so a regression is told apart from a pre-existing defect. For a pre-fix
|
||||
contrast you need `db70017c` (or `bfd5b756` for "before phase three") files swapped into the
|
||||
playground's `vendor/` — that is a sync, so **ask Nuno first** and always restore afterwards.
|
||||
|
||||
State which pest commit produced the numbers. Close with:
|
||||
|
||||
1. Whether L1–L18 all pass (the fix works).
|
||||
2. Whether A–K regressed anywhere relative to phase two's 154/156.
|
||||
3. The D1–D4 decisions as implemented.
|
||||
4. Anything still open — including G4, which will still be failing.
|
||||
|
||||
Leave the playground on `master` with only the four user-modified files dirty, no stray branches, and
|
||||
no leftover `~/.pest/tia/*` dirs beyond `laravel-4a455a95622ac0ec`.
|
||||
@@ -1,466 +0,0 @@
|
||||
# TIA write-tier conformance — phase two
|
||||
|
||||
## Your task
|
||||
|
||||
**Re-run all 156 rows of the matrix in Part 2 from scratch, against the playground app** at
|
||||
`/Users/nunomaduro/Work/projects/playground/laravel`. Every row, including the ones already marked
|
||||
VERIFIED or PASS — the point of phase two is that no row's status is trusted until it has been
|
||||
re-measured against the current code. The `Phase 1` column is prior evidence and a hint at what to
|
||||
watch, never a reason to skip a row.
|
||||
|
||||
These are **not** the `pestphp/pest` repo's own tests (`composer test`) — do not run those. Each row is
|
||||
a `pest` invocation against the playground's suite, followed by a diff of the TIA graph it wrote.
|
||||
|
||||
Report, per row: tier respected (yes/no), the graph delta under sentinel patching, and — for any
|
||||
failure — the pre-fix contrast so a regression is told apart from a pre-existing defect. Do **not**
|
||||
stop at reading this file or summarising it; the deliverable is executed results.
|
||||
|
||||
Work in this order: **Part 1 (environment traps) → Part 1b (build the fixtures — the playground has
|
||||
none of them) → Part 2 (the matrix) → Part 3 (priorities)**. The matrix is too large for one context;
|
||||
take it one lettered section at a time and report as you finish each. Sections A, B, G, I and K are
|
||||
the load-bearing ones — do those first if you run short.
|
||||
|
||||
Phase one implemented `PLAN.md` Part 1 items 1–4 plus three §5 items. This file turns the matrix into
|
||||
a conformance check rather than a bug list.
|
||||
|
||||
Code under test: `pestphp/pest` at `/Users/nunomaduro/Work/projects/pestphp/pest`, branch
|
||||
`fix/tia-filtered`, commit **`bfd5b756`** or later. Verify with
|
||||
`grep -c 'recordsEdgesInWorkers\|recordsEdges' src/Plugins/Tia.php` → at least 3 hits. Pre-fix
|
||||
baseline for every contrast is **`db70017c`**.
|
||||
|
||||
---
|
||||
|
||||
## Part 0 — What phase one changed
|
||||
|
||||
| # | Change | Files |
|
||||
|---|---|---|
|
||||
| 1 | `hasUnlocatedTestsToRerun()` stats the file, so a deleted test file is "unlocated" | `src/Plugins/Tia/Graph.php` |
|
||||
| 2 | `enterReplayMode()` uses `activateLinkTracking()` under piggyback coverage | `src/Plugins/Tia.php` |
|
||||
| 3 | `enterReplayMode()` stamps `TIA_PIGGYBACK_COVERAGE` for workers | `src/Plugins/Tia.php` |
|
||||
| 4 | `replaceEdges(…, keepExisting:)` — piggyback edges seed empty sets, never overwrite populated ones | `Graph.php`, `Tia.php` |
|
||||
| 5 | `renderFreshGraph()` stops claiming "fresh graph" when the graph is kept; reason reworded to `recording a coverage baseline` | `Tia.php` |
|
||||
| 6 | `COVERAGE_REPORT_FLAGS` + `coverageReportActive()` union over `originalArguments`; new `pestCoverageActive()` keeps the coverage-cache marker/hijack on Pest's own `--coverage` | `Tia.php` |
|
||||
| 7 | `Tia::recordsEdgesInWorkers()` + `WrapperRunner::handleTia()` inject `-d pcov.directory=<root>` into worker argv | `Tia.php`, `src/Plugins/Parallel/Paratest/WrapperRunner.php` |
|
||||
| 8 | Sequential record runs announce structural drift via `renderFreshGraph()` | `Tia.php` |
|
||||
| 9 | `Graph::getTime()` + `cachedTimeByTestId` + `resultTime()` preserve replayed durations; edge-less write guard is now `$recordsEdges = $complete && ($markKnownTestFiles \|\| $this->recordingActive)` | `Graph.php`, `Tia.php` |
|
||||
|
||||
Deliberately **not** done: `PLAN.md` §5 SIGINT propagation, §5 warning/deprecation `status=0`
|
||||
mapping, and all of §6 (playground annotation fixtures). The vacuous C rows below stay vacuous.
|
||||
|
||||
### Target-outcome changes this forces
|
||||
|
||||
Two rows in the original matrix asserted the **old**, buggy behaviour. Their targets are updated
|
||||
below — do not report them as regressions:
|
||||
|
||||
- **B10** was "`time` differs; statuses stable". Change 9 means replayed entries now **keep** their
|
||||
recorded `time`. New target: `time` differs only for tests that actually executed.
|
||||
- **B12** gains an edge-preservation assertion it never had (see K1).
|
||||
|
||||
### One known-failing repo test
|
||||
|
||||
`tests/Unit/Plugins/Tia/Graph.php:69-76` asserts `hasUnlocatedTestsToRerun('main')` is `false` for
|
||||
`tests/Feature/FooTest.php` under `new Graph(sys_get_temp_dir())` — a path that does not exist. That
|
||||
assertion encodes the I9 bug and **will fail** under change 1. It needs re-pointing at a root/file
|
||||
that exists (e.g. `dirname(__DIR__, 4)` + `'tests/Unit/Plugins/Tia/Graph.php'`). Left untouched by
|
||||
request; it is a repo-test matter, not a playground one.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Environment, and the traps in it
|
||||
|
||||
**Playground:** `/Users/nunomaduro/Work/projects/playground/laravel`
|
||||
|
||||
```bash
|
||||
cd /Users/nunomaduro/Work/projects/playground/laravel
|
||||
GRAPH="$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)/graph.json" # ~/.pest/tia/laravel-4a455a95622ac0ec
|
||||
```
|
||||
|
||||
1. **Every** pest invocation needs `PAO_DISABLE=1` — the app has `laravel/pao`, which emits JSON
|
||||
when it detects an agent.
|
||||
2. **Never run `git checkout .` or `git checkout -- composer.lock` in the playground.** It has four
|
||||
pre-existing user-modified files — `AGENTS.md`, `CLAUDE.md`, `composer.json`, `composer.lock` —
|
||||
that a blanket reset would destroy. `PLAN.md`'s "reset ritual" is unsafe as written. Scope resets
|
||||
to what you touched: `git checkout -- tests app` / `git clean -fd tests app`, and for
|
||||
`composer.lock` (needed for the drift rows) **copy it aside and copy it back**, verifying with
|
||||
`shasum`.
|
||||
3. **`vendor/pestphp/pest` is a dist copy, not a symlink.** Composer installed
|
||||
`dev-fix/tia-filtered as 5.2.0`, so edits in the pest repo do **not** reach the playground.
|
||||
|
||||
**Tell Nuno whenever a sync is needed to move forward — do not sync silently.** Say what is stale
|
||||
and what the sync would be, then wait. This includes temporarily swapping in `db70017c` files for a
|
||||
before/after contrast. When you report any playground result, state which commit produced it.
|
||||
The sync itself, once he agrees:
|
||||
```bash
|
||||
PEST=/Users/nunomaduro/Work/projects/pestphp/pest
|
||||
V=/Users/nunomaduro/Work/projects/playground/laravel/vendor/pestphp/pest
|
||||
for f in src/Plugins/Tia.php src/Plugins/Tia/Graph.php \
|
||||
src/Plugins/Parallel/Paratest/WrapperRunner.php; do cp "$PEST/$f" "$V/$f"; done
|
||||
```
|
||||
Verify with `grep -c recordsEdgesInWorkers "$V/src/Plugins/Tia.php"` → `1`. **Always restore the
|
||||
current version before continuing** after a pre-fix contrast.
|
||||
4. **Coverage driver:** pcov only, no xdebug. `ini_get('pcov.directory')` is `''` by default — that
|
||||
emptiness is the entire mechanism behind G12.
|
||||
5. **Suite shape as found:** 7 tests in 5 files — `tests/Unit/{CalculatorTest,ExampleTest,GreeterTest,
|
||||
TrioTest}.php`, `tests/Feature/ExampleTest.php`. A healthy sequential graph is **`files=22`,
|
||||
5 edge keys, self-edge on every test** (`PLAN.md`'s `files=25` is stale). Pre-fix parallel gives
|
||||
`files=4` with zero self-edges. **These numbers shift the moment you add the Part 1b fixtures** —
|
||||
re-derive them once, after the fixtures land, and use the new numbers throughout. The invariants
|
||||
that do *not* shift: sequential and parallel must agree, and every test must have a self-edge.
|
||||
6. **Sentinel patching is the only reliable discriminator** between "wrote identical values" and
|
||||
"wrote nothing": rewrite every cached result to `time=9.999 assertions=42`, snapshot, run the
|
||||
case, diff. A canary test absent from `edges` is unreliable under `--filter` because it never
|
||||
matches the filter and so never runs.
|
||||
7. Seeding a cached failure needs `--fresh` (or an env-driven flaky fixture): `--tia` on a clean
|
||||
green tree replays rather than executes, so it can never cache a failure. Working recipe — break
|
||||
an assertion, `pest --tia --fresh`, then restore the source.
|
||||
8. Comment-only edits to a test file are **not** changes (AST-level hashing). Use semantic edits.
|
||||
9. The shell is zsh — build commands with arrays or `eval`; unquoted `$args` does not word-split.
|
||||
|
||||
### Graph summariser
|
||||
|
||||
Write this to a scratch path and use it for every diff.
|
||||
|
||||
```php
|
||||
<?php // summarise.php <graph.json> [label]
|
||||
$g = json_decode((string) file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
|
||||
$files = $g['files'] ?? []; $edges = $g['edges'] ?? [];
|
||||
echo ($argv[2] ?? $argv[1])."\n files=".count($files).' edges keys='.count($edges)."\n";
|
||||
$dirs = []; foreach ($files as $f) { $dirs[explode('/', (string) $f)[0]] = true; }
|
||||
ksort($dirs); echo ' file dirs: '.implode(', ', array_keys($dirs))."\n";
|
||||
ksort($edges);
|
||||
foreach ($edges as $test => $ids) {
|
||||
$self = 'no';
|
||||
foreach ((array) $ids as $id) { if (($files[$id] ?? null) === $test) { $self = 'YES'; break; } }
|
||||
echo sprintf(" %-42s n=%-3d self=%s\n", $test, count((array) $ids), $self);
|
||||
}
|
||||
foreach ($g['baselines'] ?? [] as $branch => $b) {
|
||||
$r = $b['results'] ?? [];
|
||||
echo " baseline[$branch]: n=".count($r).' sha='.substr((string) ($b['sha'] ?? '-'), 0, 7)."\n";
|
||||
foreach ($r as $id => $x) {
|
||||
echo sprintf(" %-58s status=%d time=%s asserts=%d file=%s\n", substr((string) $id, -58),
|
||||
$x['status'], $x['time'], $x['assertions'], $x['file'] ?? '-');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Normalised edge-set equality (for G12 / I5 / K1):
|
||||
|
||||
```php
|
||||
<?php // edgediff.php <a.json> <b.json>
|
||||
function edges(string $p): array {
|
||||
$g = json_decode((string) file_get_contents($p), true, 512, JSON_THROW_ON_ERROR); $out = [];
|
||||
foreach ($g['edges'] as $t => $ids) { $s = array_map(fn ($i) => $g['files'][$i], $ids); sort($s); $out[$t] = $s; }
|
||||
ksort($out); return $out;
|
||||
}
|
||||
$a = edges($argv[1]); $b = edges($argv[2]);
|
||||
echo $a === $b ? "IDENTICAL edge sets\n" : "DIFFER\n";
|
||||
foreach ($a as $t => $s) {
|
||||
$m = array_diff($s, $b[$t] ?? []); $e = array_diff($b[$t] ?? [], $s);
|
||||
if ($m || $e) printf(" %s: -%d +%d\n", $t, count($m), count($e));
|
||||
}
|
||||
```
|
||||
|
||||
### Per-case loop
|
||||
|
||||
```bash
|
||||
git checkout -- tests app 2>/dev/null; git clean -qfd tests app
|
||||
rm -rf "$(PAO_DISABLE=1 ./vendor/bin/pest --baseline)"
|
||||
PAO_DISABLE=1 ./vendor/bin/pest --tia >/dev/null 2>&1 # seed a healthy graph
|
||||
# sentinel-patch every result to time=9.999 assertions=42, snapshot, run the case, diff
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1b — Fixtures you must build first
|
||||
|
||||
**The playground has none of the fixtures the matrix depends on.** Verified inventory: the only test
|
||||
files are `tests/Unit/{CalculatorTest,ExampleTest,GreeterTest,TrioTest}.php` and
|
||||
`tests/Feature/ExampleTest.php` — 7 plain tests, zero occurrences of `->group()`, `->only()`,
|
||||
`->skip()`, `->todo()`, `->note()`, `->flaky()`, `->covers()`, `->uses()`, `->issue()`, `->pr()`,
|
||||
`->ticket()`, `->assignee()`, datasets, or any `FLAKY_OK`-style env hook. `phpunit.xml` defines only
|
||||
the `Unit` and `Feature` testsuites.
|
||||
|
||||
So roughly 30 rows below cannot run as written until you create the fixtures. Build them **all up
|
||||
front, in one commit**, then re-derive the baseline graph shape once and use those numbers for the
|
||||
whole sweep — every fixture you add changes `files`, the `edges` key count and `n`, so adding them
|
||||
piecemeal invalidates earlier rows.
|
||||
|
||||
| Fixture to create | Rows that need it |
|
||||
|---|---|
|
||||
| a test with `->group('smoke')` | C7, C8, C9, C10 |
|
||||
| an env-driven flaky test (passes iff `FLAKY_OK=1`) | E1, E2, E3, E4 |
|
||||
| a `->skip()`ed test | E9, D8 |
|
||||
| a `->todo()` test | E10, C23, C39 |
|
||||
| a risky test (no assertions; pair with `--disallow-test-output`) | E11, D7 |
|
||||
| a test that triggers a PHPUnit warning | D6 |
|
||||
| a test calling `markTestIncomplete()` | D9 |
|
||||
| a test that triggers a deprecation | D10 |
|
||||
| a dataset test with ≥3 rows | E13, E14 |
|
||||
| `->only()` — added and removed per case, not left in | C31, C32, G7 |
|
||||
| `->covers(App\Services\Calculator::class)` | C18 |
|
||||
| `->uses(...)` / `UsesClass` annotation | C19 |
|
||||
| `->note(...)` | C24 |
|
||||
| `->flaky()` **annotation** (distinct from the env-driven flaky test above) | C25 |
|
||||
| `->issue(123)`, `->pr(1)`, `->ticket('X')`, `->assignee('X')` | C26, C27, C28, C37, C38 |
|
||||
|
||||
This is `PLAN.md` §6's "test-harness gaps to close before re-running", now itemised: without these,
|
||||
the listed rows' graph-invariant assertions match zero tests and **prove nothing** — they pass
|
||||
vacuously. Any row still marked "(vacuous)" in Part 2 is vacuous *only because* its fixture is
|
||||
missing; once you add the fixture, treat the row as unverified and make it load-bearing.
|
||||
|
||||
Rows needing an *action* rather than a fixture — breaking `trio one` for the D rows, an uncommitted
|
||||
test edit for `--dirty`, branch renames and a non-git dir for H5–H10, `--mutate` against
|
||||
`app/Services` for the F rows — are fine as written; `pestphp/pest-plugin-mutate` is installed.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Full case matrix
|
||||
|
||||
Tiers: **COMPLETE** may change everything · **RESULTS-ONLY** (RO) may change only
|
||||
`baselines[<branch>].results` for tests that ran, and must never remove an entry, add a result for a
|
||||
test file absent from `edges`, or alter `sha`/`tree`/`edges`/`files`/`fingerprint` ·
|
||||
**HARD-SUPPRESSED** may change nothing.
|
||||
|
||||
Phase-1 column: **VERIFIED** = re-run against the fixed code in the phase-one session, pre-fix
|
||||
contrast captured · **PASS (sweep)** = passed in the original `db70017` sweep and *not* re-checked
|
||||
since the changes — these are the bulk of phase two's work · **SKIP** = not runnable as written.
|
||||
|
||||
### A — Setup & sanity
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| A1 | `composer show pestphp/pest` | version `dev-fix/tia-filtered` | PASS (sweep) |
|
||||
| A2 | `pest --baseline` | prints an existing dir; exit 0 | VERIFIED |
|
||||
| A3 | delete graph, `pest --tia` | graph.json created; one `edges` key per test file; one result per test | VERIFIED |
|
||||
| A4 | `git status` after reset ritual | clean *except the four user-modified files* (see trap 2) | VERIFIED |
|
||||
| A5 | `extension_loaded("pcov")` | `true` | VERIFIED |
|
||||
| A6 | delete graph, plain `pest` | no graph created | PASS (sweep) |
|
||||
| A7 | delete graph, `pest --filter=adds` | no graph created | PASS (sweep) |
|
||||
| A8 | two consecutive `pest --tia` | second replays everything; `sha` unchanged | VERIFIED |
|
||||
|
||||
### B — COMPLETE runs still write
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| B1 | `pest --tia` (clean) | replays; graph written; `sha` = `git rev-parse HEAD` | VERIFIED |
|
||||
| B2 | `pest --no-tia` | results refreshed; prune applied; `sha`/`tree`/`edges` unchanged | PASS (sweep) — recheck under change 9 |
|
||||
| B3 | `pest` (plain) | same as B2 | PASS (sweep) — recheck under change 9 |
|
||||
| B4 | `pest --tia --fresh` | graph purged and rebuilt; `edges` rebuilt; `files` repopulated | VERIFIED |
|
||||
| B5 | `pest --bail`, all green | COMPLETE — graph written, prune applied | PASS (sweep) |
|
||||
| B6 | edit `Calculator.php`, `pest --tia` | `CalculatorTest` re-runs, others replay; `tree` updated | PASS (sweep) |
|
||||
| B7 | delete `trio three`, `pest --tia` | its result pruned; `trio one`/`two` remain | PASS (sweep) |
|
||||
| B8 | delete `GreeterTest.php`, `pest --tia` | result and edges survive (missing-file prune is `--fresh`-only) | PASS (sweep) |
|
||||
| B9 | B8 then `pest --tia --fresh` | entry gone; `files` drops `Greeter.php` too | PASS (sweep) |
|
||||
| B10 | `pest --tia` twice | **UPDATED TARGET:** `time` differs only for tests that executed; replayed entries keep their recorded `time`; statuses stable | VERIFIED |
|
||||
| B11 | add a test file, `pest --tia` | new `edges` key **and** new result appear in the **same** run | VERIFIED (regression guard for change 9) |
|
||||
| B12 | `pest --tia --coverage` | completes; graph written; coverage report prints | VERIFIED |
|
||||
|
||||
### C — Selection narrowing → RESULTS-ONLY
|
||||
|
||||
All rows: RO invariants hold. "Notice" = `TIA does not apply to partial runs — running the selected tests directly.`
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| C1 | `pest --filter="adds numbers"` | RO; no notice | PASS (sweep) |
|
||||
| C2 | `pest --tia --filter="adds numbers"` | RO; notice | PASS (sweep) |
|
||||
| C3 | `pest --filter="trio one"` | RO; `trio two`/`three` byte-identical | PASS (sweep) |
|
||||
| C4 | `pest --filter="trio"` | RO; all three update; nothing else does | PASS (sweep) |
|
||||
| C5 | `pest --exclude-filter="Feature"` | RO; no notice; no prune | PASS (sweep) |
|
||||
| C6 | `pest --tia --exclude-filter="Feature"` | RO; notice | PASS (sweep) |
|
||||
| C7 | `pest --group=smoke` | RO; no notice | PASS (sweep) |
|
||||
| C8 | `pest --tia --group=smoke` | RO; notice | PASS (sweep) |
|
||||
| C9 | `pest --exclude-group=smoke` | RO; no notice; no prune | PASS (sweep) |
|
||||
| C10 | `pest --tia --exclude-group=smoke` | RO; notice | PASS (sweep) |
|
||||
| C11 | `pest tests/Unit` | RO; no notice; Feature entry not pruned | PASS (sweep) |
|
||||
| C12 | `pest --tia tests/Unit` | RO; notice | PASS (sweep) |
|
||||
| C13 | `pest tests/Unit/TrioTest.php` | RO; no notice | PASS (sweep) |
|
||||
| C14 | `pest --tia tests/Unit/TrioTest.php` | RO; notice | PASS (sweep) |
|
||||
| C15 | `pest --testsuite=Unit` | RO; no notice | PASS (sweep) |
|
||||
| C16 | `pest --tia --testsuite=Unit` | RO; notice | PASS (sweep) |
|
||||
| C17 | `pest --tia --exclude-testsuite=Feature` | RO; notice | PASS (sweep) |
|
||||
| C18 | `pest --tia --covers='App\Services\Calculator'` | RO; notice | PASS (sweep) |
|
||||
| C19 | `pest --tia --uses='App\Services\Calculator'` | RO; notice | PASS (vacuous — needs fixture) |
|
||||
| C20 | `pest --tia --test-suffix=Test.php` | RO even though all tests run; `edges`/`files`/`n` unchanged | PASS (sweep) |
|
||||
| C21 | `pest --dirty` with an uncommitted test edit | RO; no notice | PASS (sweep) |
|
||||
| C22 | `pest --tia --dirty` | RO; notice | PASS (sweep) |
|
||||
| C23 | `pest --tia --todos` | RO; notice | PASS (vacuous) |
|
||||
| C24 | `pest --tia --notes` | RO; notice | PASS (vacuous) |
|
||||
| C25 | `pest --tia --flaky` | RO; notice | PASS (vacuous) |
|
||||
| C26 | `pest --tia --issue=123` | RO; notice | PASS (vacuous) |
|
||||
| C27 | `pest --tia --pr=1` | RO; notice | PASS (vacuous) |
|
||||
| C28 | `pest --tia --pull-request=1` | RO; notice | PASS (vacuous) |
|
||||
| C29 | `pest --tia --shard=1/2` | RO; notice; no prune | PASS (sweep) |
|
||||
| C30 | `pest --tia --shard=2/2` | RO; notice; C29+C30 covers the suite; neither prunes | PASS (sweep) |
|
||||
| C31 | `->only()` on `trio one`, `pest --tia` | RO; no notice; siblings untouched | PASS (sweep) |
|
||||
| C32 | `->only()` on `trio one`, plain `pest` | RO; no notice | PASS (sweep) |
|
||||
| C33 | `pest --tia --filtered --filter=adds` | RO; notice; filtered yields to narrowing | PASS (sweep) |
|
||||
| C34 | `PEST_TIA=1 pest --filter=adds` | RO; notice | PASS (sweep) |
|
||||
| C35 | `PEST_TIA_FILTERED=1 pest --filter=adds` | RO; notice (regression guard) | PASS (sweep) |
|
||||
| C36 | `pest --filtered --filter=adds` | RO; notice | PASS (sweep) |
|
||||
| C37 | `pest --tia --ticket=X` | RO; notice. `--ticket` **is** a real option (`src/Plugins/Snapshot.php:83`) — it narrows legitimately | PASS (vacuous) |
|
||||
| C38 | `pest --tia --assignee=X` | RO; notice (`src/Plugins/Snapshot.php:81`) | PASS (vacuous) |
|
||||
| C39 | `pest --tia --todo` | RO; notice; matches nothing | PASS (vacuous) |
|
||||
|
||||
### D — Truncation → RESULTS-ONLY
|
||||
|
||||
D1–D5, D11–D13, D16 precondition: `trio one` broken. D6–D10 run against a green suite so the `--stop-on-*` trigger is the only narrowing.
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| D1 | `pest --bail` | RO; `trio one` = `status=7`; siblings survive unchanged | PASS (sweep) |
|
||||
| D2 | `pest --retry` | RO; siblings survive | PASS (sweep) |
|
||||
| D3 | `pest --stop-on-failure` | RO | PASS (sweep) |
|
||||
| D4 | `pest --stop-on-defect` | RO | PASS (sweep) |
|
||||
| D5 | `pest --stop-on-error` (no error occurs) | COMPLETE — nothing stopped it | PASS (sweep) |
|
||||
| D6 | `pest --stop-on-warning` | RO when it fires. Warning stored as `status=0`, not `6` | PASS (sweep) |
|
||||
| D7 | `pest --stop-on-risky --disallow-test-output` | RO; `status=5` | PASS (sweep) |
|
||||
| D8 | `pest --stop-on-skipped` | RO; `status=1` | PASS (sweep) |
|
||||
| D9 | `pest --stop-on-incomplete` | RO; `status=2` | PASS (sweep) |
|
||||
| D10 | `pest --stop-on-deprecation` | RO. Deprecation stored as `status=0`, not `4` | PASS (sweep) |
|
||||
| D11 | `pest --tia --bail` | RO | PASS (sweep) |
|
||||
| D12 | `pest --bail --filter=trio` | RO (narrowed *and* truncated) | PASS (sweep) |
|
||||
| D13 | `stopOnFailure="true"` in `phpunit.xml`, plain `pest` | RO — caught via `stoppedEarly()`, not flag matching | PASS (sweep) |
|
||||
| D14 | D13 config, green suite | COMPLETE | PASS (sweep) |
|
||||
| D15 | `pest --bail`, last test in run order fails | RO — over-conservative but safe; nothing was skipped | PASS (sweep) |
|
||||
| D16 | `pest --tia --filtered --bail`, broken affected test | RO | PASS (sweep) |
|
||||
|
||||
### E — Result merge semantics
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| E1 | cached `flaky`=7, `FLAKY_OK=1 pest --filter=flaky` | flips to `status=0`; all other results byte-identical | PASS (sweep) |
|
||||
| E2 | cached `flaky`=0, `pest --filter=flaky` (env unset) | flips to `status=7` | PASS (sweep) |
|
||||
| E3 | after E1, `pest --tia --filtered` clean | `No affected tests found`; zero graph delta | PASS (sweep) |
|
||||
| E4 | after E2, `pest --tia --filtered` | re-runs, `from 1 previously unsuccessful test` | PASS (sweep) |
|
||||
| E5 | any partial run | `assertions` and `time` update for the test that ran (it executed, so change 9 does not apply) | PASS (sweep) |
|
||||
| E6 | any partial run | `message` of untouched tests unchanged | PASS (sweep) |
|
||||
| E7 | two sequential partial runs on different tests | each updates only its own entry; both persist | PASS (sweep) |
|
||||
| E8 | `pest --filter="trio one"` | siblings keep exact `status`/`time`/`assertions`/`message` | PASS (sweep) |
|
||||
| E9 | partial run of a `->skip()`ed test | `status=1` | PASS (sweep) |
|
||||
| E10 | partial run of a `->todo()` test | `status=1`, `assertions=0`, `message="__TODO__"` | PASS (sweep) |
|
||||
| E11 | partial run of a risky test | `status=5` | PASS (sweep) |
|
||||
| E12 | any partial run | `fingerprint` byte-identical | PASS (sweep) |
|
||||
| E13 | dataset test, `--filter` matching one row | that row updates; other rows survive (the old prune bug) | PASS (sweep) |
|
||||
| E14 | dataset test, full `--tia` after deleting a row | the deleted row is pruned | PASS (sweep) |
|
||||
|
||||
### F — Guard rails
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| F1 | test absent from `edges`, `pest --filter="brand new"` | no result recorded | VERIFIED |
|
||||
| F2 | complete `pest --tia` first, then the same filter | result is recorded | VERIFIED |
|
||||
| F3 | `pest --mutate --path=app/Services --covered-only` | statuses/`edges`/`sha`/`tree`/`files` identical; only `time` may change | PASS (sweep) |
|
||||
| F4 | `pest --mutate --parallel --path=… --covered-only` | same (observed: zero delta) | PASS (sweep) — recheck under change 7 |
|
||||
| F5 | weakened test so a mutant survives, `pest --mutate` | no status change in the baseline | PASS (sweep) |
|
||||
| F6 | `pest --mutate` with the graph deleted | no graph created | PASS (sweep) |
|
||||
| F7 | mutation subprocess env | carries `PEST_MUTATION_TESTING`; `--mutate` stripped from argv | PASS (sweep) |
|
||||
| F8 | grep baseline after `--mutate` | no mutation-flavoured messages | PASS (sweep) |
|
||||
|
||||
### G — Parallel
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| G1 | `pest --tia --parallel` (clean) | COMPLETE — graph written; all 7 results present | VERIFIED |
|
||||
| G2 | `pest --tia --parallel --filter=adds` | RO | PASS (sweep) |
|
||||
| G3 | `pest --parallel --bail`, `trio one` broken | RO; siblings survive | PASS (sweep) |
|
||||
| G4 | `pest --tia --parallel --bail` | RO | PASS (sweep) |
|
||||
| G5 | `pest --tia --filtered --parallel` after a source edit | narrows to affected; writes | VERIFIED (see I5) |
|
||||
| G6 | `pest --tia --parallel` | worker results reach the parent baseline | VERIFIED |
|
||||
| G7 | `->only()` + `pest --tia --parallel` | RO | PASS (sweep) |
|
||||
| G8 | `pest --tia --parallel --shard=1/2` | RO | PASS (sweep) |
|
||||
| G9 | broken test in one worker, `pest --parallel --bail` | whole run is RO, not just that worker's slice | PASS (sweep) |
|
||||
| G10 | `pest --parallel --retry` | `InvalidOption`; graph untouched | PASS (sweep) |
|
||||
| G11 | `pest --tia --parallel --fresh` | graph rebuilt | VERIFIED |
|
||||
| G12 | `edges` after `pest --tia` vs `pest --tia --parallel --fresh` | **equivalent edge sets** — `files=22`, self-edge on all 5 tests, both | **VERIFIED (fixed)** — pre-fix: `files=4`, 0 self-edges |
|
||||
|
||||
### H — Baseline key / branch resolution
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| H1 | `pest --tia` on `master` | only a `master` key | VERIFIED |
|
||||
| H2 | plain `pest` on `master` | writes `master`, not `main` | PASS (sweep) |
|
||||
| H3 | `pest --bail` truncated on `master` | no `main` key minted | PASS (sweep) |
|
||||
| H4 | `pest --filter=adds` on `master` | no `main` key minted | PASS (sweep) |
|
||||
| H5 | `git branch -m main`, full `pest --tia` | single `main` key | PASS (sweep) |
|
||||
| H6 | `git checkout -b feature/x`, `pest --tia` | `feature/x` key appears; reads fall back to the existing baseline | PASS (sweep) |
|
||||
| H7 | detached HEAD, `pest --tia` | falls back to the `main` key for both reads and writes; no `HEAD` key | PASS (sweep) |
|
||||
| H8 | after every C and D case | no baseline key other than the real branch | PASS (sweep) |
|
||||
| H9 | non-git dir, `pest --tia` | `MissingDependency` — `The feature "Tia mode" requires "git".` | PASS (sweep) |
|
||||
| H10 | non-git dir, plain `pest` | runs normally; no baseline dir created | PASS (sweep) |
|
||||
|
||||
### I — Filtered mode
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| I1 | clean + green, `pest --tia --filtered` | `No affected tests found`; zero graph delta | PASS (sweep) |
|
||||
| I2 | edit `Calculator.php` | only `CalculatorTest` runs | VERIFIED |
|
||||
| I3 | cached failure, clean tree | re-runs it, `from 1 previously unsuccessful test` | PASS (sweep) |
|
||||
| I4 | I2 with sentinels | `tree` updated; exactly one entry rewritten; all others retained | PASS (sweep) |
|
||||
| I5 | `pest --tia --filtered --parallel` | same narrowing as I2 **and `edges` unchanged** | **VERIFIED (fixed)** — 1 affected test, edges identical |
|
||||
| I6 | `PEST_TIA_FILTERED=1 pest --tia` | identical delta to I4 | PASS (sweep) |
|
||||
| I7 | `pest --tia --filtered tests/Unit` | explicit path wins; filtered off; RO; notice | PASS (sweep) |
|
||||
| I8 | `pest --tia --filtered --coverage-text` | filtered mode disabled by an active coverage report → full suite runs | **VERIFIED (fixed)** — pre-fix: `No affected tests found` |
|
||||
| I9 | cached failure whose test file was deleted | WARN `Some cached tests due a re-run could not be located on disk` + `Running the full suite with replay instead of a filtered run` | **VERIFIED (fixed)** — pre-fix: `No tests found`, exit 0 green |
|
||||
| I10 | `pest --tia --filtered` with no baseline yet | records a baseline instead of filtering | PASS (sweep) |
|
||||
| I11 | edit a Blade view a Feature test renders | the Feature test is selected | PASS (sweep) |
|
||||
| I12 | edit `composer.lock` | fingerprint drift → full rebuild, **and the reason is printed sequentially**: `fresh graph (composer.lock changed)` | **VERIFIED (fixed)** — pre-fix: bare `Running in TIA mode.` |
|
||||
|
||||
### J — Interactions & regressions
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| J1 | `pest --tia --fresh --filter=adds` | graph **not** purged; RO | PASS (sweep) |
|
||||
| J2 | `pest --tia --refetch --filter=adds` | refetch not performed; RO | PASS (sweep) |
|
||||
| J3 | `pest --no-tia --filter=adds` | RO — narrowing wins over `--no-tia`; no notice | PASS (sweep) |
|
||||
| J4 | `pest --tia --no-tia` | TIA disabled; COMPLETE | PASS (sweep) |
|
||||
| J5 | `<groups><exclude><group>integration</group></exclude></groups>`, `pest --tia` | COMPLETE, no notice — an always-in-force filter applies to baseline runs too | PASS (sweep) |
|
||||
| J6 | `pest --tia --compact` | COMPLETE | PASS (sweep) |
|
||||
| J7 | `pest --tia -v` | COMPLETE | PASS (sweep) |
|
||||
| J8 | `pest --tia --profile` | COMPLETE | PASS (sweep) |
|
||||
| J9 | `pest --tia --order-by=random` | COMPLETE | PASS (sweep) |
|
||||
| J10 | `pest --tia --random-order-seed=1234` | COMPLETE | PASS (sweep) |
|
||||
| J11 | `pest --tia --repeat=2` | COMPLETE — repetition is not narrowing | **SKIP** — `--repeat` is not a Pest option |
|
||||
| J12 | `pest --filter=adds` twice | only the executed test's `time` differs; rest byte-identical | PASS (sweep) |
|
||||
| J13 | `pest --tia --min=50` | COMPLETE (silent no-op without `--coverage`) | PASS (sweep) |
|
||||
| J14 | SIGINT mid-suite | RO — an interrupted run is truncated | PASS (sweep) — signal still does not reach the re-exec'd child (not fixed) |
|
||||
| J15 | delete the graph, `pest --tia --filtered` | records a baseline rather than erroring | PASS (sweep) |
|
||||
| J16 | corrupt `graph.json`, `pest --tia` | recovers by rebuilding; no crash | PASS (sweep) |
|
||||
|
||||
### K — New rows for the phase-one fixes
|
||||
|
||||
These assert behaviour no original row covered. All were verified in phase one; re-run them as
|
||||
regression guards.
|
||||
|
||||
| # | Case | Target outcome | Phase 1 |
|
||||
|---|---|---|---|
|
||||
| K1 | healthy `pest --tia` graph, then `pest --tia --coverage` | `edges` **byte-identical** — piggyback data may seed empty sets, never narrow populated ones; `files=22` stays `22` | **VERIFIED (fixed)** — pre-fix: `ExampleTest` 16→2 edges, self-edges lost |
|
||||
| K2 | same run's headline | `Experimental TIA mode enabled / recording a coverage baseline.` — no false `fresh graph` | **VERIFIED (fixed)** |
|
||||
| K3 | `pest --tia` ×3 on a clean tree | replayed entries keep their recorded `time` across all three | **VERIFIED (fixed)** — pre-fix: `0.053 → 0.001 → 0.001 → 0.001` |
|
||||
| K4 | add a test file, one `pest --tia` (graph exists, fingerprint matches → replay+refresh) | result **and** edges appear in that same run; `n` grows by 1 | **VERIFIED** — regression guard for change 9 |
|
||||
| K5 | add a test file, plain `pest --no-tia` | no result written for it; `n` unchanged; no edges | **VERIFIED (fixed)** — pre-fix: edge-less result inflated `n` |
|
||||
| K6 | `pest --tia --parallel` worker argv | carries `-d pcov.directory=<projectRoot>`; `PEST_TIA` unset; not re-exec'd | **VERIFIED** — probe `bin/worker.php` |
|
||||
| K7 | second `pest --tia --coverage` (cache primed → replay) | recorder uses link tracking only, does not clear PHPUnit's data mid-collection; coverage report intact | Not yet measured — **new work** |
|
||||
| K8 | `pest --tia --parallel --coverage` | workers read `TIA_PIGGYBACK_COVERAGE`; no widened pcov scope; report intact | Not yet measured — **new work** |
|
||||
| K9 | `pest --tia --filtered --coverage-html=<dir>` / `--coverage-clover=<file>` | filtered mode off, same as I8, for every flag in `COVERAGE_REPORT_FLAGS` | Not yet measured — **new work** |
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Priorities for phase two
|
||||
|
||||
0. **Part 1b fixtures** — nothing in C, D6–D10, or E9–E14 means anything until they exist.
|
||||
1. **K7, K8, K9** — the only rows never measured. K7/K8 exercise changes 2 and 3, which were
|
||||
reasoned about but not observed; K9 covers the seven `COVERAGE_REPORT_FLAGS` beyond
|
||||
`--coverage-text`.
|
||||
2. **B2, B3, F4** and all of **D** and **E** — change 9 touched the shared result-write path, and
|
||||
these are the rows that exercise it hardest. `$recordsEdges` is the thing to falsify: it must be
|
||||
false for every partial and every non-recording run.
|
||||
3. **F3–F8** — change 7 injects a `-d` into worker argv; `--mutate --parallel` is the one place that
|
||||
both spawns workers and must write nothing.
|
||||
4. **H1–H10** — untouched by these changes; cheapest bulk confirmation.
|
||||
5. Raw PHPUnit coverage flags print **no report at all** in Pest, with or without TIA
|
||||
(`--no-tia --coverage-text` is equally silent). Pre-existing, unrelated to change 6 — do not
|
||||
chase it as a regression, but it means I8/K9 can only assert the selection half.
|
||||
|
||||
Report per row: tier respected (yes/no), the graph delta under sentinel patching, and for any
|
||||
failure the pre-fix contrast (write `git show db70017c:<file>` into vendor, re-run, restore) so a
|
||||
regression is told apart from a pre-existing defect.
|
||||
@@ -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)**.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ $bootPest = (static function (): void {
|
||||
exit;
|
||||
}
|
||||
|
||||
// It must be a 1 byte string to ensure filesize() is equal to the number of tests executed
|
||||
$exitCode = $application->runTest(realpath(trim($testPath)));
|
||||
|
||||
fwrite($statusFile, (string) $exitCode);
|
||||
|
||||
+12
-9
@@ -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.2",
|
||||
"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"
|
||||
},
|
||||
@@ -71,6 +72,7 @@
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
"preferred-install": "dist",
|
||||
"process-timeout": 0,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true
|
||||
}
|
||||
@@ -94,7 +96,8 @@
|
||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --group=integration -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||
"test:tia": "php bin/pest --group=tia -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots --exclude-group=tia",
|
||||
"test": [
|
||||
"@test:lint",
|
||||
"@test:type:check",
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
@@ -86,7 +54,7 @@ final class JunitXmlLogger
|
||||
{
|
||||
private readonly Printer $printer;
|
||||
|
||||
private readonly Converter $converter; // pest-added
|
||||
private readonly Converter $converter;
|
||||
|
||||
private DOMDocument $document;
|
||||
|
||||
@@ -144,8 +112,7 @@ final class JunitXmlLogger
|
||||
public function __construct(Printer $printer, Facade $facade)
|
||||
{
|
||||
$this->printer = $printer;
|
||||
$this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath); // pest-added
|
||||
|
||||
$this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath);
|
||||
$this->registerSubscribers($facade);
|
||||
$this->createDocument();
|
||||
}
|
||||
@@ -160,10 +127,9 @@ final class JunitXmlLogger
|
||||
public function testSuiteStarted(Started $event): void
|
||||
{
|
||||
$testSuite = $this->document->createElement('testsuite');
|
||||
$testSuite->setAttribute('name', $this->converter->getTestSuiteName($event->testSuite())); // pest-changed
|
||||
|
||||
$testSuite->setAttribute('name', $this->converter->getTestSuiteName($event->testSuite()));
|
||||
if ($event->testSuite()->isForTestClass()) {
|
||||
$testSuite->setAttribute('file', $this->converter->getTestSuiteLocation($event->testSuite()) ?? ''); // pest-changed
|
||||
$testSuite->setAttribute('file', $this->converter->getTestSuiteLocation($event->testSuite()) ?? '');
|
||||
}
|
||||
|
||||
if ($this->testSuiteLevel > 0) {
|
||||
@@ -380,12 +346,11 @@ final class JunitXmlLogger
|
||||
|
||||
assert($this->currentTestCase !== null);
|
||||
|
||||
$buffer = $this->converter->getTestCaseMethodName($event->test()); // pest-changed
|
||||
|
||||
$buffer = $this->converter->getTestCaseMethodName($event->test());
|
||||
$throwable = $event->throwable();
|
||||
$buffer .= trim(
|
||||
$this->converter->getExceptionMessage($throwable).PHP_EOL. // pest-changed
|
||||
$this->converter->getExceptionDetails($throwable), // pest-changed
|
||||
$this->converter->getExceptionMessage($throwable).PHP_EOL.
|
||||
$this->converter->getExceptionDetails($throwable),
|
||||
);
|
||||
|
||||
$fault = $this->document->createElement(
|
||||
@@ -485,18 +450,15 @@ final class JunitXmlLogger
|
||||
$testCase = $this->document->createElement('testcase');
|
||||
|
||||
$test = $event->test();
|
||||
$file = $this->converter->getTestCaseLocation($test); // pest-added
|
||||
|
||||
$testCase->setAttribute('name', $this->converter->getTestCaseMethodName($test)); // pest-changed
|
||||
$testCase->setAttribute('file', $file); // pest-changed
|
||||
|
||||
$file = $this->converter->getTestCaseLocation($test);
|
||||
$testCase->setAttribute('name', $this->converter->getTestCaseMethodName($test));
|
||||
$testCase->setAttribute('file', $file);
|
||||
if ($test->isTestMethod()) {
|
||||
assert($test instanceof TestMethod);
|
||||
|
||||
// $testCase->setAttribute('line', (string) $test->line()); // pest-removed
|
||||
$className = $this->converter->getTrimmedTestClassName($test); // pest-added
|
||||
$testCase->setAttribute('class', $className); // pest-changed
|
||||
$testCase->setAttribute('classname', str_replace('\\', '.', $className)); // pest-changed
|
||||
$className = $this->converter->getTrimmedTestClassName($test);
|
||||
$testCase->setAttribute('class', $className);
|
||||
$testCase->setAttribute('classname', str_replace('\\', '.', $className));
|
||||
}
|
||||
|
||||
$this->currentTestCase = $testCase;
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2011 Brian Scaturro
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ParaTest\WrapperRunner;
|
||||
@@ -34,10 +10,6 @@ use function preg_match;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||
* instead of being treated as unexpected output.
|
||||
*/
|
||||
final readonly class ProgressPrinterOutput implements Printer
|
||||
{
|
||||
@@ -48,7 +20,6 @@ final readonly class ProgressPrinterOutput implements Printer
|
||||
|
||||
public function print(string $buffer): void
|
||||
{
|
||||
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||
if (
|
||||
$buffer === "\n"
|
||||
|| preg_match('/^ +$/', $buffer) === 1
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
@@ -48,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
|
||||
@@ -106,75 +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) {
|
||||
// Handles:
|
||||
// * testAssertEqualsSucceeds#4
|
||||
// * testAssertEqualsSucceeds#4-8
|
||||
if (preg_match('/^(.*?)#(\d+)(?:-(\d+))?$/', $filter, $matches)) {
|
||||
if (isset($matches[3]) && $matches[2] < $matches[3]) {
|
||||
$filter = sprintf(
|
||||
'%s.*with 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],
|
||||
);
|
||||
}
|
||||
} // Handles:
|
||||
// * testDetermineJsonError@JSON_ERROR_NONE
|
||||
// * testDetermineJsonError@JSON.*
|
||||
elseif (preg_match('/^(.*?)@(.+)$/', $filter, $matches)) {
|
||||
$filter = sprintf(
|
||||
'%s.*with data set "%s"$',
|
||||
$matches[1],
|
||||
$matches[2],
|
||||
);
|
||||
}
|
||||
|
||||
// Escape delimiters in regular expression. Do NOT use preg_quote,
|
||||
// to keep magic characters.
|
||||
$filter = sprintf(
|
||||
'/%s/i',
|
||||
str_replace(
|
||||
'/',
|
||||
'\\/',
|
||||
$filter,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'regularExpression' => $filter,
|
||||
'dataSetMinimum' => $dataSetMinimum,
|
||||
'dataSetMaximum' => $dataSetMaximum,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of PHPUnit.
|
||||
*
|
||||
* (c) Sebastian Bergmann <sebastian@phpunit.de>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace PHPUnit\Runner\ResultCache;
|
||||
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
use 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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache version.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PHPUnit\Runner;
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
@@ -46,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;
|
||||
@@ -78,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>
|
||||
@@ -97,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,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)) {
|
||||
@@ -134,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);
|
||||
@@ -149,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
|
||||
@@ -186,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;
|
||||
@@ -242,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>
|
||||
@@ -257,12 +257,19 @@ final class TestSuiteSorter
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator callback function to sort tests for "reach failure as fast as possible".
|
||||
*
|
||||
* 1. sort tests by defect weight defined in self::DEFECT_SORT_WEIGHT
|
||||
* 2. when tests are equally defective, sort the fastest to the front
|
||||
* 3. do not reorder successful 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);
|
||||
@@ -271,72 +278,72 @@ final class TestSuiteSorter
|
||||
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
|
||||
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
|
||||
|
||||
if ($priorityA !== $priorityB) {
|
||||
// Sort defect weight descending
|
||||
return $priorityB <=> $priorityA;
|
||||
}
|
||||
|
||||
if ($priorityA > 0 || $priorityB > 0) {
|
||||
return $this->cmpDuration($a, $b);
|
||||
}
|
||||
|
||||
// do not change execution order
|
||||
return 0;
|
||||
return $priorityB <=> $priorityA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares test duration for sorting tests by duration ascending.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares test size for sorting tests small->medium->large->unknown.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible.
|
||||
* The algorithm will leave the tests in original running order when it can.
|
||||
* For more details see the documentation for test dependencies.
|
||||
*
|
||||
* Short description of algorithm:
|
||||
* 1. Pick the next Test from remaining tests to be checked for dependencies.
|
||||
* 2. If the test has no dependencies: mark done, start again from the top
|
||||
* 3. If the test has dependencies but none left to do: mark done, start again from the top
|
||||
* 4. When we reach the end add any leftover tests to the end. These will be marked 'skipped' during execution.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
// Pest: Fast-path. If no test in this suite declares dependencies, the
|
||||
// original O(N^2) algorithm is wasted work — it would splice each test
|
||||
// one-by-one back into the same order. The check deliberately walks
|
||||
// TestCase instances directly instead of calling TestSuite::requires(),
|
||||
// because the latter lazily builds TestSuite::provides() via
|
||||
// ExecutionOrderDependency::mergeUnique, which is O(N^2) in the total
|
||||
// number of tests. With thousands of tests that single call alone can
|
||||
// burn several seconds before the sort even begins. Reading the
|
||||
// cached TestCase::$dependencies property stays O(N) and costs nothing
|
||||
// when no test uses `->depends()` / PHPUnit `@depends`.
|
||||
if (! $this->anyTestHasDependencies($tests)) {
|
||||
return $tests;
|
||||
}
|
||||
@@ -345,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));
|
||||
@@ -353,18 +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.
|
||||
*
|
||||
* Walks `TestSuite` containers recursively and inspects each `TestCase`
|
||||
* directly so it never triggers `TestSuite::provides()`, which is O(N^2)
|
||||
* in the total number of aggregated tests.
|
||||
*
|
||||
* @param iterable<Test> $tests
|
||||
*/
|
||||
private function anyTestHasDependencies(iterable $tests): bool
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
@@ -51,10 +19,6 @@ use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||
* "T" and swallows the "S" that follows it.
|
||||
*/
|
||||
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
@@ -67,9 +31,6 @@ final readonly class TestSkippedSubscriber extends Subscriber implements Skipped
|
||||
$this->printer()->testSkipped();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a "T" to the standard PHPUnit output to indicate a todo item.
|
||||
*/
|
||||
private function printTodoItem(): void
|
||||
{
|
||||
$mirror = new ReflectionClass($this->printer());
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
* This file is part of PHPUnit.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"exclude": [
|
||||
"tests/Fixtures/Suites"
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
"Pint/phpdoc_type_annotations_only": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ return RectorConfig::configure()
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
__DIR__.'/tests/Fixtures/Arch',
|
||||
// Fixture suites are pinned by the TeamCity / JUnit snapshots, down to
|
||||
// the line numbers — rewriting their source would break them.
|
||||
__DIR__.'/tests/Fixtures/Suites',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
@@ -40,13 +38,7 @@ return RectorConfig::configure()
|
||||
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
||||
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
||||
],
|
||||
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
||||
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
||||
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
||||
UseToMatchArrayRector::class,
|
||||
// Test fixtures rely on "unused" constructors, params and properties
|
||||
// (resolved via the container or read through reflection), so the
|
||||
// dead-code and return-type rules below must not touch the test suite.
|
||||
RemoveEmptyClassMethodRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
|
||||
@@ -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`
|
||||
@@ -13,15 +13,11 @@ use Pest\Expectation;
|
||||
abstract class AbstractPreset // @pest-arch-ignore-line
|
||||
{
|
||||
/**
|
||||
* The expectations.
|
||||
*
|
||||
* @var array<int, Expectation<mixed>|ArchExpectation>
|
||||
*/
|
||||
protected array $expectations = [];
|
||||
|
||||
/**
|
||||
* Creates a new preset instance.
|
||||
*
|
||||
* @param array<int, string> $userNamespaces
|
||||
*/
|
||||
public function __construct(
|
||||
@@ -31,15 +27,11 @@ abstract class AbstractPreset // @pest-arch-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract public function execute(): void;
|
||||
|
||||
/**
|
||||
* Ignores the given "targets" or "dependencies".
|
||||
*
|
||||
* @param array<int, string>|string $targetsOrDependencies
|
||||
*/
|
||||
final public function ignoring(array|string $targetsOrDependencies): void
|
||||
@@ -51,8 +43,6 @@ abstract class AbstractPreset // @pest-arch-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the given callback for each namespace.
|
||||
*
|
||||
* @param callable(Expectation<string>): ArchExpectation ...$callbacks
|
||||
*/
|
||||
final public function eachUserNamespace(callable ...$callbacks): void
|
||||
@@ -64,9 +54,6 @@ abstract class AbstractPreset // @pest-arch-ignore-line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes the expectations.
|
||||
*/
|
||||
final public function flush(): void
|
||||
{
|
||||
$this->expectations = [];
|
||||
|
||||
@@ -14,8 +14,6 @@ use Pest\Expectation;
|
||||
final class Custom extends AbstractPreset
|
||||
{
|
||||
/**
|
||||
* Creates a new preset instance.
|
||||
*
|
||||
* @param array<int, string> $userNamespaces
|
||||
* @param Closure(array<int, string>): array<Expectation<mixed>|ArchExpectation> $execute
|
||||
*/
|
||||
@@ -27,17 +25,11 @@ final class Custom extends AbstractPreset
|
||||
parent::__construct($userNamespaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the preset.
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$this->expectations = ($this->execute)($this->userNamespaces);
|
||||
|
||||
@@ -11,9 +11,6 @@ use Throwable;
|
||||
*/
|
||||
final class Laravel extends AbstractPreset
|
||||
{
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$this->expectations[] = expect('App\Traits')
|
||||
|
||||
@@ -9,9 +9,6 @@ namespace Pest\ArchPresets;
|
||||
*/
|
||||
final class Php extends AbstractPreset
|
||||
{
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$this->expectations[] = expect([
|
||||
|
||||
@@ -12,9 +12,6 @@ use Pest\Expectation;
|
||||
*/
|
||||
final class Relaxed extends AbstractPreset
|
||||
{
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$this->eachUserNamespace(
|
||||
|
||||
@@ -9,9 +9,6 @@ namespace Pest\ArchPresets;
|
||||
*/
|
||||
final class Security extends AbstractPreset
|
||||
{
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$this->expectations[] = expect([
|
||||
|
||||
@@ -12,9 +12,6 @@ use Pest\Expectation;
|
||||
*/
|
||||
final class Strict extends AbstractPreset
|
||||
{
|
||||
/**
|
||||
* Executes the arch preset.
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$this->eachUserNamespace(
|
||||
|
||||
@@ -13,8 +13,6 @@ use PHPUnit\Util\ExcludeList;
|
||||
final class BootExcludeList implements Bootstrapper
|
||||
{
|
||||
/**
|
||||
* The directories to exclude.
|
||||
*
|
||||
* @var array<int, non-empty-string>
|
||||
*/
|
||||
private const array EXCLUDE_LIST = [
|
||||
@@ -25,9 +23,6 @@ final class BootExcludeList implements Bootstrapper
|
||||
'stubs',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boots the "exclude list" for PHPUnit to ignore Pest files.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$baseDirectory = dirname(__DIR__, 2);
|
||||
|
||||
@@ -21,8 +21,6 @@ use function Pest\testDirectory;
|
||||
final class BootFiles implements Bootstrapper
|
||||
{
|
||||
/**
|
||||
* The structure of the tests directory.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const array STRUCTURE = [
|
||||
@@ -33,9 +31,6 @@ final class BootFiles implements Bootstrapper
|
||||
'Pest.php',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boots the structure of the tests directory.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
@@ -67,9 +62,6 @@ final class BootFiles implements Bootstrapper
|
||||
$this->bootDatasets($testsPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads, if possible, the given file.
|
||||
*/
|
||||
private function load(string $filename): void
|
||||
{
|
||||
if (! Str::endsWith($filename, '.php')) {
|
||||
|
||||
@@ -14,18 +14,12 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final readonly class BootKernelDump implements Bootstrapper
|
||||
{
|
||||
/**
|
||||
* Creates a new Boot Kernel Dump instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
// ...
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the kernel dump.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Container::getInstance()->add(KernelDump::class, $kernelDump = new KernelDump(
|
||||
|
||||
@@ -13,14 +13,12 @@ use Pest\Exceptions\ShouldNotHappen;
|
||||
final class BootOverrides implements Bootstrapper
|
||||
{
|
||||
/**
|
||||
* The list of files to be overridden.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
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',
|
||||
@@ -30,9 +28,6 @@ final class BootOverrides implements Bootstrapper
|
||||
'Logging/JUnit/JunitXmlLogger.php',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boots the list of files to be overridden.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
foreach (self::FILES as $file) {
|
||||
|
||||
@@ -16,8 +16,6 @@ use PHPUnit\Event\Subscriber;
|
||||
final readonly class BootSubscribers implements Bootstrapper
|
||||
{
|
||||
/**
|
||||
* The list of Subscribers.
|
||||
*
|
||||
* @var array<int, class-string<Subscriber>>
|
||||
*/
|
||||
private const array SUBSCRIBERS = [
|
||||
@@ -35,19 +33,19 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnNoticeTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPhpNoticeTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnDeprecationTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPhpDeprecationTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnWarningTriggered::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPhpWarningTriggered::class,
|
||||
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new instance of the Boot Subscribers.
|
||||
*/
|
||||
public function __construct(
|
||||
private Container $container,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Boots the list of Subscribers.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
foreach (self::SUBSCRIBERS as $subscriber) {
|
||||
|
||||
@@ -13,18 +13,12 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final readonly class BootView implements Bootstrapper
|
||||
{
|
||||
/**
|
||||
* Creates a new instance of the Boot View.
|
||||
*/
|
||||
public function __construct(
|
||||
private OutputInterface $output
|
||||
) {
|
||||
// ..
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the view renderer.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
View::renderUsing($this->output);
|
||||
|
||||
@@ -16,22 +16,13 @@ use function Termwind\renderUsing;
|
||||
*/
|
||||
final class Events
|
||||
{
|
||||
/**
|
||||
* Sets the output.
|
||||
*/
|
||||
private static ?OutputInterface $output = null;
|
||||
|
||||
/**
|
||||
* Sets the output.
|
||||
*/
|
||||
public static function setOutput(OutputInterface $output): void
|
||||
{
|
||||
self::$output = $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires before the test method description is printed.
|
||||
*/
|
||||
public static function beforeTestMethodDescription(TestResult $result, string $description): string
|
||||
{
|
||||
if (($context = $result->context) === []) {
|
||||
@@ -73,9 +64,6 @@ final class Events
|
||||
return $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after the test method description is printed.
|
||||
*/
|
||||
public static function afterTestMethodDescription(TestResult $result): void
|
||||
{
|
||||
if (($context = $result->context) === []) {
|
||||
|
||||
@@ -14,8 +14,6 @@ trait Expectable
|
||||
/**
|
||||
* @template TValue
|
||||
*
|
||||
* Creates a new Expectation.
|
||||
*
|
||||
* @param TValue $value
|
||||
* @return Expectation<TValue>
|
||||
*/
|
||||
|
||||
@@ -14,15 +14,11 @@ use Closure;
|
||||
trait Extendable
|
||||
{
|
||||
/**
|
||||
* The list of extends.
|
||||
*
|
||||
* @var array<string, Closure>
|
||||
*/
|
||||
private static array $extends = [];
|
||||
|
||||
/**
|
||||
* Register a new extend.
|
||||
*
|
||||
* @param-closure-this T $extend
|
||||
*/
|
||||
public function extend(string $name, Closure $extend): void
|
||||
@@ -30,9 +26,6 @@ trait Extendable
|
||||
static::$extends[$name] = $extend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given extend name is registered.
|
||||
*/
|
||||
public static function hasExtend(string $name): bool
|
||||
{
|
||||
return array_key_exists($name, static::$extends);
|
||||
|
||||
@@ -9,33 +9,21 @@ namespace Pest\Concerns\Logging;
|
||||
*/
|
||||
trait WritesToConsole
|
||||
{
|
||||
/**
|
||||
* Writes the given success message to the console.
|
||||
*/
|
||||
private function writeSuccess(string $message): void
|
||||
{
|
||||
$this->writePestTestOutput($message, 'fg-green, bold', '✓');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given error message to the console.
|
||||
*/
|
||||
private function writeError(string $message): void
|
||||
{
|
||||
$this->writePestTestOutput($message, 'fg-red, bold', '⨯');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given warning message to the console.
|
||||
*/
|
||||
private function writeWarning(string $message): void
|
||||
{
|
||||
$this->writePestTestOutput($message, 'fg-yellow, bold', '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the give message to the console.
|
||||
*/
|
||||
private function writePestTestOutput(string $message, string $color, string $symbol): void
|
||||
{
|
||||
$this->writeWithColor($color, "$symbol ", false);
|
||||
|
||||
@@ -12,30 +12,21 @@ use Closure;
|
||||
trait Pipeable
|
||||
{
|
||||
/**
|
||||
* The list of pipes.
|
||||
*
|
||||
* @var array<string, array<Closure(Closure, mixed ...$arguments): void>>
|
||||
*/
|
||||
private static array $pipes = [];
|
||||
|
||||
/**
|
||||
* The list of interceptors.
|
||||
*
|
||||
* @var array<string, array<Closure(Closure, mixed ...$arguments): void>>
|
||||
*/
|
||||
private static array $interceptors = [];
|
||||
|
||||
/**
|
||||
* Register a pipe to be applied before an expectation is checked.
|
||||
*/
|
||||
public function pipe(string $name, Closure $pipe): void
|
||||
{
|
||||
self::$pipes[$name][] = $pipe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an interceptor that should replace an existing expectation.
|
||||
*
|
||||
* @param string|Closure(mixed $value, mixed ...$arguments):bool $filter
|
||||
*/
|
||||
public function intercept(string $name, string|Closure $filter, Closure $handler): void
|
||||
@@ -60,8 +51,6 @@ trait Pipeable
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of pipes by the given name.
|
||||
*
|
||||
* @return array<int, Closure>
|
||||
*/
|
||||
private function pipes(string $name, object $context, string $scope): array
|
||||
|
||||
@@ -11,8 +11,6 @@ trait Retrievable
|
||||
{
|
||||
/**
|
||||
* @template TRetrievableValue
|
||||
*
|
||||
* Safely retrieve the value at the given key from an object or array.
|
||||
* @template TRetrievableValue
|
||||
*
|
||||
* @param array<string, TRetrievableValue>|object $value
|
||||
|
||||
+17
-123
@@ -37,105 +37,53 @@ use Throwable;
|
||||
*/
|
||||
trait Testable
|
||||
{
|
||||
/**
|
||||
* The test's description.
|
||||
*/
|
||||
private string $__description;
|
||||
|
||||
/**
|
||||
* The test's latest description.
|
||||
*/
|
||||
private static string $__latestDescription;
|
||||
|
||||
/**
|
||||
* The test's assignees.
|
||||
*/
|
||||
private static array $__latestAssignees = [];
|
||||
|
||||
/**
|
||||
* The test's notes.
|
||||
*/
|
||||
private static array $__latestNotes = [];
|
||||
|
||||
/**
|
||||
* The test's issues.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
private static array $__latestIssues = [];
|
||||
|
||||
/**
|
||||
* The test's PRs.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
private static array $__latestPrs = [];
|
||||
|
||||
/**
|
||||
* The test's describing, if any.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $__describing = [];
|
||||
|
||||
/**
|
||||
* Whether the test has ran or not.
|
||||
*/
|
||||
public bool $__ran = false;
|
||||
|
||||
/**
|
||||
* The active replay mode for this test, set in `setUp()` and checked
|
||||
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
||||
*/
|
||||
private ReplayType $__replay = ReplayType::None;
|
||||
|
||||
/**
|
||||
* The cached assertion count to replay, captured when entering replay mode.
|
||||
*/
|
||||
private int $__replayAssertions = 0;
|
||||
|
||||
/**
|
||||
* The test's test closure.
|
||||
*/
|
||||
private Closure $__test;
|
||||
|
||||
/**
|
||||
* The test's before each closure.
|
||||
*/
|
||||
private ?Closure $__beforeEach = null;
|
||||
|
||||
/**
|
||||
* The test's after each closure.
|
||||
*/
|
||||
private ?Closure $__afterEach = null;
|
||||
|
||||
/**
|
||||
* The test's before all closure.
|
||||
*/
|
||||
private static ?Closure $__beforeAll = null;
|
||||
|
||||
/**
|
||||
* The test's after all closure.
|
||||
*/
|
||||
private static ?Closure $__afterAll = null;
|
||||
|
||||
/**
|
||||
* The list of snapshot changes, if any.
|
||||
*/
|
||||
private array $__snapshotChanges = [];
|
||||
|
||||
/**
|
||||
* Resets the test case static properties.
|
||||
*/
|
||||
public static function flush(): void
|
||||
{
|
||||
self::$__beforeAll = null;
|
||||
self::$__afterAll = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new "note" to the Test Case.
|
||||
*/
|
||||
public function note(array|string $note): self
|
||||
{
|
||||
$note = is_array($note) ? $note : [$note];
|
||||
@@ -145,9 +93,6 @@ trait Testable
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new "setUpBeforeClass" to the Test Case.
|
||||
*/
|
||||
public function __addBeforeAll(?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof Closure) {
|
||||
@@ -159,9 +104,6 @@ trait Testable
|
||||
: $hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new "tearDownAfterClass" to the Test Case.
|
||||
*/
|
||||
public function __addAfterAll(?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof Closure) {
|
||||
@@ -173,25 +115,16 @@ trait Testable
|
||||
: $hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new "setUp" to the Test Case.
|
||||
*/
|
||||
public function __addBeforeEach(?Closure $hook): void
|
||||
{
|
||||
$this->__addHook('__beforeEach', $hook);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new "tearDown" to the Test Case.
|
||||
*/
|
||||
public function __addAfterEach(?Closure $hook): void
|
||||
{
|
||||
$this->__addHook('__afterEach', $hook);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new "hook" to the Test Case.
|
||||
*/
|
||||
private function __addHook(string $property, ?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof Closure) {
|
||||
@@ -203,9 +136,6 @@ trait Testable
|
||||
: $hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called before the first test of this Test Case is run.
|
||||
*/
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
@@ -223,9 +153,6 @@ trait Testable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called after the last test of this Test Case is run.
|
||||
*/
|
||||
public static function tearDownAfterClass(): void
|
||||
{
|
||||
$afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename);
|
||||
@@ -239,9 +166,6 @@ trait Testable
|
||||
parent::tearDownAfterClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets executed before the Test Case.
|
||||
*/
|
||||
protected function setUp(...$arguments): void
|
||||
{
|
||||
TestSuite::getInstance()->test = $this;
|
||||
@@ -286,9 +210,6 @@ trait Testable
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
// Marks the replay before the branches below throw, so `tearDown`
|
||||
// short-circuits for every replayed result — the throwing branches
|
||||
// never reach `parent::setUp`, so no user hook may run after them.
|
||||
$this->__replay = $replay;
|
||||
|
||||
match ($replay) {
|
||||
@@ -328,12 +249,8 @@ trait Testable
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test case properties from TestSuite.
|
||||
*/
|
||||
public function __initializeTestCase(): void
|
||||
{
|
||||
// Return if the test case has already been initialized
|
||||
if (isset($this->__test)) {
|
||||
return;
|
||||
}
|
||||
@@ -355,9 +272,6 @@ trait Testable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets executed after the Test Case.
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__replay !== ReplayType::None) {
|
||||
@@ -385,8 +299,6 @@ trait Testable
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the Test Case current test.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
@@ -432,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) {
|
||||
@@ -457,9 +371,6 @@ trait Testable
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given exception matches PHPUnit's expected exception.
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||
@@ -494,27 +405,30 @@ trait Testable
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the passed arguments. Any Closures will be bound to the testcase and resolved.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function __resolveTestArguments(array $arguments): array
|
||||
{
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
if ($method->repetitions > 1) {
|
||||
// If the test is repeated, the first argument is the iteration number
|
||||
// we need to move it to the end of the arguments list
|
||||
// so that the datasets are the first n arguments
|
||||
// and the iteration number is the last argument
|
||||
$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) {
|
||||
@@ -555,8 +469,6 @@ trait Testable
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures dataset items count matches underlying test case required parameters
|
||||
*
|
||||
* @throws ReflectionException
|
||||
* @throws DatasetArgumentsMismatch
|
||||
*/
|
||||
@@ -596,9 +508,6 @@ trait Testable
|
||||
return ExceptionTrace::ensure(fn (): mixed => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the given preset on the test.
|
||||
*/
|
||||
public function preset(): Preset
|
||||
{
|
||||
return new Preset;
|
||||
@@ -614,33 +523,21 @@ trait Testable
|
||||
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
|
||||
}
|
||||
|
||||
/**
|
||||
* The printable test case name.
|
||||
*/
|
||||
public static function getPrintableTestCaseName(): string
|
||||
{
|
||||
return preg_replace('/P\\\/', '', self::class, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The printable test case method name.
|
||||
*/
|
||||
public function getPrintableTestCaseMethodName(): string
|
||||
{
|
||||
return $this->__description;
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest printable test case method name.
|
||||
*/
|
||||
public static function getLatestPrintableTestCaseMethodName(): string
|
||||
{
|
||||
return self::$__latestDescription ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The printable test case method context.
|
||||
*/
|
||||
public static function getPrintableContext(): array
|
||||
{
|
||||
return [
|
||||
@@ -651,9 +548,6 @@ trait Testable
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a shell for the test case.
|
||||
*/
|
||||
public function shell(): void
|
||||
{
|
||||
Shell::open();
|
||||
|
||||
@@ -14,31 +14,19 @@ use Pest\PendingCalls\UsesCall;
|
||||
*/
|
||||
final readonly class Configuration
|
||||
{
|
||||
/**
|
||||
* The filename of the configuration.
|
||||
*/
|
||||
private string $filename;
|
||||
|
||||
/**
|
||||
* Creates a new configuration instance.
|
||||
*/
|
||||
public function __construct(
|
||||
string $filename,
|
||||
) {
|
||||
$this->filename = str_ends_with($filename, DIRECTORY_SEPARATOR.'Pest.php') ? dirname($filename) : $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the given classes and traits in the given targets.
|
||||
*/
|
||||
public function in(string ...$targets): UsesCall
|
||||
{
|
||||
return new UsesCall($this->filename, [])->in(...$targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on where is called, it will extend the given classes and traits globally or locally.
|
||||
*/
|
||||
public function extend(string ...$classAndTraits): UsesCall
|
||||
{
|
||||
return new UsesCall(
|
||||
@@ -47,97 +35,62 @@ final readonly class Configuration
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on where is called, it will extend the given classes and traits globally or locally.
|
||||
*/
|
||||
public function extends(string ...$classAndTraits): UsesCall
|
||||
{
|
||||
return $this->extend(...$classAndTraits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on where is called, it will add the given groups globally or locally.
|
||||
*/
|
||||
public function group(string ...$groups): UsesCall
|
||||
{
|
||||
return new UsesCall($this->filename, [])->group(...$groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all tests in the current file to be run exclusively.
|
||||
*/
|
||||
public function only(): void
|
||||
{
|
||||
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on where is called, it will extend the given classes and traits globally or locally.
|
||||
*/
|
||||
public function use(string ...$classAndTraits): UsesCall
|
||||
{
|
||||
return $this->extend(...$classAndTraits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depending on where is called, it will extend the given classes and traits globally or locally.
|
||||
*/
|
||||
public function uses(string ...$classAndTraits): UsesCall
|
||||
{
|
||||
return $this->extends(...$classAndTraits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the printer configuration.
|
||||
*/
|
||||
public function printer(): Configuration\Printer
|
||||
{
|
||||
return new Configuration\Printer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the presets configuration.
|
||||
*/
|
||||
public function presets(): Configuration\Presets
|
||||
{
|
||||
return new Configuration\Presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the project configuration.
|
||||
*/
|
||||
public function project(): Configuration\Project
|
||||
{
|
||||
return Configuration\Project::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the browser configuration.
|
||||
*/
|
||||
public function browser(): Browser\Configuration
|
||||
{
|
||||
return new Browser\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the evals configuration.
|
||||
*/
|
||||
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
||||
{
|
||||
return new Evals\Configuration; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TIA (Test Impact Analysis) configuration.
|
||||
*/
|
||||
public function tia(): Plugins\Tia\Configuration
|
||||
{
|
||||
return new Plugins\Tia\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies calls to the uses method.
|
||||
*
|
||||
* @param array<array-key, mixed> $arguments
|
||||
*/
|
||||
public function __call(string $name, array $arguments): mixed
|
||||
|
||||
@@ -9,9 +9,6 @@ use Pest\Preset;
|
||||
|
||||
final class Presets
|
||||
{
|
||||
/**
|
||||
* Creates a custom preset instance, and adds it to the list of presets.
|
||||
*/
|
||||
public function custom(string $name, Closure $execute): void
|
||||
{
|
||||
Preset::custom($name, $execute);
|
||||
|
||||
@@ -11,9 +11,6 @@ use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
||||
*/
|
||||
final readonly class Printer
|
||||
{
|
||||
/**
|
||||
* Sets the theme to compact.
|
||||
*/
|
||||
public function compact(): self
|
||||
{
|
||||
DefaultPrinter::compact(true);
|
||||
|
||||
@@ -10,42 +10,27 @@ namespace Pest\Configuration;
|
||||
final class Project
|
||||
{
|
||||
/**
|
||||
* The assignees link.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public string $assignees = '';
|
||||
|
||||
/**
|
||||
* The issues link.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public string $issues = '';
|
||||
|
||||
/**
|
||||
* The PRs link.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public string $prs = '';
|
||||
|
||||
/**
|
||||
* The singleton instance.
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Creates a new instance of the project.
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
return self::$instance ??= new self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test project to GitHub.
|
||||
*/
|
||||
public function github(string $project): self
|
||||
{
|
||||
$this->issues = "https://github.com/{$project}/issues/%s";
|
||||
@@ -56,12 +41,8 @@ final class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test project to GitLab.
|
||||
*/
|
||||
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
||||
{
|
||||
// Simple way to ensure only the host is used
|
||||
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
||||
|
||||
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
||||
@@ -72,9 +53,6 @@ final class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test project to Bitbucket.
|
||||
*/
|
||||
public function bitbucket(string $project): self
|
||||
{
|
||||
$this->issues = "https://bitbucket.org/{$project}/issues/%s";
|
||||
@@ -85,9 +63,6 @@ final class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test project to Jira.
|
||||
*/
|
||||
public function jira(string $namespace, string $project): self
|
||||
{
|
||||
$this->issues = "https://{$namespace}.atlassian.net/browse/{$project}-%s";
|
||||
@@ -97,9 +72,6 @@ final class Project
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test project to custom.
|
||||
*/
|
||||
public function custom(string $issues, string $prs, string $assignees): self
|
||||
{
|
||||
$this->issues = $issues;
|
||||
|
||||
@@ -12,8 +12,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
final readonly class Help
|
||||
{
|
||||
/**
|
||||
* The Command messages.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const array HELP_MESSAGES = [
|
||||
@@ -24,17 +22,11 @@ final readonly class Help
|
||||
' <info>--group=<fg=cyan><name></></info> Only runs tests from the specified group(s)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new Console Command instance.
|
||||
*/
|
||||
public function __construct(private OutputInterface $output)
|
||||
{
|
||||
// ..
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the Console Command.
|
||||
*/
|
||||
public function __invoke(): void
|
||||
{
|
||||
foreach (self::HELP_MESSAGES as $message) {
|
||||
|
||||
@@ -18,8 +18,6 @@ use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||
final readonly class Thanks
|
||||
{
|
||||
/**
|
||||
* The support options.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const array FUNDING_MESSAGES = [
|
||||
@@ -33,19 +31,13 @@ final readonly class Thanks
|
||||
'Sponsor' => 'https://github.com/sponsors/nunomaduro',
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new Console Command instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private InputInterface $input,
|
||||
private OutputInterface $output
|
||||
) {
|
||||
// ..
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the Console Command.
|
||||
*/
|
||||
public function __invoke(): void
|
||||
{
|
||||
$bootstrapper = new BootView($this->output);
|
||||
|
||||
@@ -9,8 +9,5 @@ namespace Pest\Contracts;
|
||||
*/
|
||||
interface Bootstrapper
|
||||
{
|
||||
/**
|
||||
* Boots the bootstrapper.
|
||||
*/
|
||||
public function boot(): void;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName as
|
||||
*/
|
||||
interface HasPrintableTestCaseName extends BaseHasPrintableTestCaseName
|
||||
{
|
||||
// ..
|
||||
//
|
||||
}
|
||||
|
||||
@@ -11,13 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
interface Panicable
|
||||
{
|
||||
/**
|
||||
* Renders the panic on the given output.
|
||||
*/
|
||||
public function render(OutputInterface $output): void;
|
||||
|
||||
/**
|
||||
* The exit code to be used.
|
||||
*/
|
||||
public function exitCode(): int;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,5 @@ namespace Pest\Contracts\Plugins;
|
||||
*/
|
||||
interface AddsOutput
|
||||
{
|
||||
/**
|
||||
* Adds output after the Test Suite execution.
|
||||
*/
|
||||
public function addOutput(int $exitCode): int;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,5 @@ namespace Pest\Contracts\Plugins;
|
||||
*/
|
||||
interface Bootable
|
||||
{
|
||||
/**
|
||||
* Boots the plugin.
|
||||
*/
|
||||
public function boot(): void;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ namespace Pest\Contracts\Plugins;
|
||||
interface HandlesArguments
|
||||
{
|
||||
/**
|
||||
* Adds arguments before the Test Suite execution.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
*/
|
||||
|
||||
@@ -10,8 +10,6 @@ namespace Pest\Contracts\Plugins;
|
||||
interface HandlesOriginalArguments
|
||||
{
|
||||
/**
|
||||
* Adds original arguments before the Test Suite execution.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function handleOriginalArguments(array $arguments): void;
|
||||
|
||||
@@ -9,8 +9,5 @@ namespace Pest\Contracts\Plugins;
|
||||
*/
|
||||
interface Terminable
|
||||
{
|
||||
/**
|
||||
* Terminates the plugin.
|
||||
*/
|
||||
public function terminate(): void;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,5 @@ namespace Pest\Contracts;
|
||||
|
||||
interface TestCaseFilter
|
||||
{
|
||||
/**
|
||||
* Whether the test case is accepted.
|
||||
*/
|
||||
public function accept(string $testCaseFilename): bool;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,5 @@ use Pest\Factories\TestCaseMethodFactory;
|
||||
|
||||
interface TestCaseMethodFilter
|
||||
{
|
||||
/**
|
||||
* Whether the test case method is accepted.
|
||||
*/
|
||||
public function accept(TestCaseMethodFactory $factory): bool;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ use Pest\Factories\Attribute;
|
||||
final class Attributes
|
||||
{
|
||||
/**
|
||||
* Evaluates the given attributes and returns the code.
|
||||
*
|
||||
* @param iterable<int, Attribute> $attributes
|
||||
*/
|
||||
public static function code(iterable $attributes): string
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class AfterAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||
parent::__construct(sprintf('The [afterAll] hook is already defined in [%s]. Each test file may only define it once.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class AfterAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
parent::__construct(sprintf('The [afterAll] hook may not be used inside a [describe] block. Please move it to the top level of [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class AfterBeforeTestFunction extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||
parent::__construct(sprintf('The [after] hook may only be chained onto [beforeEach] inside a [describe] block. Please move it inside one in [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class BeforeAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||
parent::__construct(sprintf('The [beforeAll] hook is already defined in [%s]. Each test file may only define it once.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class BeforeAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
parent::__construct(sprintf('The [beforeAll] hook may not be used inside a [describe] block. Please move it to the top level of [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class DatasetAlreadyExists extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $name, string $scope)
|
||||
{
|
||||
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||
parent::__construct(sprintf('A dataset named [%s] is already registered in [%s]. Please choose a different name.', $name, $scope));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ final class DatasetArgumentsMismatch extends Exception
|
||||
public function __construct(int $requiredCount, int $suppliedCount)
|
||||
{
|
||||
if ($requiredCount <= $suppliedCount) {
|
||||
parent::__construct('Test argument names and dataset keys do not match');
|
||||
parent::__construct('The test arguments do not match the dataset keys. Please make sure each argument is named after a key in the dataset.');
|
||||
} else {
|
||||
parent::__construct(sprintf('Test expects %d arguments but dataset only provides %d', $requiredCount, $suppliedCount));
|
||||
parent::__construct(sprintf('The test expects [%d] argument(s), but the dataset only provides [%d].', $requiredCount, $suppliedCount));
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class DatasetDoesNotExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $name)
|
||||
{
|
||||
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
parent::__construct(sprintf("A dataset named [%s] does not exist. You may create one using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,16 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
final class DatasetMissing extends BadFunctionCallException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*
|
||||
* @param array<string, string> $arguments
|
||||
*/
|
||||
public function __construct(string $file, string $name, array $arguments)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'A test with the description [%s] has [%d] argument(s) ([%s]) and no dataset(s) provided in [%s]',
|
||||
'The test [%s] in [%s] expects [%d] argument(s) ([%s]), but no dataset was provided. Please chain [with()] onto the test to supply one.',
|
||||
$name,
|
||||
$file,
|
||||
count($arguments),
|
||||
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),
|
||||
$file,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,8 @@ use Exception;
|
||||
*/
|
||||
final class ExpectationNotFound extends Exception
|
||||
{
|
||||
/**
|
||||
* Creates a new ExpectationNotFound instance from the given name.
|
||||
*/
|
||||
public static function fromName(string $name): ExpectationNotFound
|
||||
{
|
||||
return new self("Expectation [$name] does not exist.");
|
||||
return new self("The expectation [$name] does not exist. You may register it using [expect()->extend()].");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class FileOrFolderNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||
parent::__construct(sprintf('The file or folder [%s] could not be found. Please check the path and try again.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class InvalidArgumentException extends BaseInvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $message)
|
||||
{
|
||||
parent::__construct($message, 1);
|
||||
|
||||
@@ -21,6 +21,6 @@ final class InvalidExpectation extends LogicException implements ExceptionInterf
|
||||
*/
|
||||
public static function fromMethods(array $methods): never
|
||||
{
|
||||
throw new self(sprintf('Expectation [%s] is not valid.', implode('->', $methods)));
|
||||
throw new self(sprintf('The expectation [%s] does not exist. Please check the spelling, or register it using [expect()->extend()].', implode('->', $methods)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@ final class InvalidExpectationValue extends InvalidArgumentException
|
||||
*/
|
||||
public static function expected(string $type): never
|
||||
{
|
||||
throw new self(sprintf('Invalid expectation value type. Expected [%s].', $type));
|
||||
throw new self(sprintf('This expectation may only be used on a value of type [%s].', $type));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class InvalidOption extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $message)
|
||||
{
|
||||
parent::__construct($message, 1);
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class InvalidPestCommand extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Please run [./vendor/bin/pest] instead.');
|
||||
parent::__construct('Pest must be run through its own binary. Please run [./vendor/bin/pest] instead.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,19 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance for the given class name.
|
||||
*/
|
||||
public static function fromClassName(string $filename, string $className): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the file.',
|
||||
$filename,
|
||||
$className,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Exception instance for the given namespace.
|
||||
*/
|
||||
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, because [%s] may not be used as a namespace name. Please rename that folder.',
|
||||
$filename,
|
||||
$namespace,
|
||||
$part,
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class MissingDependency extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $feature, string $dependency)
|
||||
{
|
||||
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency));
|
||||
parent::__construct(sprintf('The [%s] feature requires [%s]. Please install it and try again.', $feature, $dependency));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class NoDirtyTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Renders the panic on the given output.
|
||||
*/
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
@@ -28,9 +25,6 @@ final class NoDirtyTestsFound extends InvalidArgumentException implements Except
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit code to be used.
|
||||
*/
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
|
||||
@@ -12,15 +12,12 @@ use RuntimeException;
|
||||
*/
|
||||
final class ShouldNotHappen extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(Exception $exception)
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
parent::__construct(sprintf(<<<'EOF'
|
||||
This should not happen - please create an new issue here: https://github.com/pestphp/pest/issues
|
||||
This should not have happened. Please report it here: https://github.com/pestphp/pest/issues
|
||||
|
||||
Issue: %s
|
||||
PHP version: %s
|
||||
@@ -29,9 +26,6 @@ EOF
|
||||
, $message, phpversion(), PHP_OS), 1, $exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance of should not happen without a specific exception.
|
||||
*/
|
||||
public static function fromMessage(string $message): ShouldNotHappen
|
||||
{
|
||||
return new ShouldNotHappen(new Exception($message));
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class TestAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $fileName, string $description)
|
||||
{
|
||||
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||
parent::__construct(sprintf('A test named [%s] already exists in [%s]. Please give this test a different description.', $description, $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,10 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class TestCaseAlreadyInUse extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $inUse, string $newOne, string $folder)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Test case [%s] can not be used. The folder [%s] already uses the test case [%s].',
|
||||
'The test case [%s] may not be used here. The folder [%s] is already bound to the test case [%s].',
|
||||
$newOne,
|
||||
$folder,
|
||||
$inUse,
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $testCaseClass)
|
||||
{
|
||||
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||
parent::__construct(sprintf('The class or trait [%s] could not be found. Please check the name, and make sure it is autoloadable.', $testCaseClass));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,14 +15,11 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class TestClosureMustNotBeStatic extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(TestCaseMethodFactory $method)
|
||||
{
|
||||
parent::__construct(
|
||||
sprintf(
|
||||
'Test closure must not be static. Please remove the [static] keyword from the [%s] method in [%s].',
|
||||
'Test closures may not be static. Please remove the [static] keyword from the test [%s] in [%s].',
|
||||
$method->description,
|
||||
$method->filename
|
||||
)
|
||||
|
||||
@@ -14,11 +14,8 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
*/
|
||||
final class TestDescriptionMissing extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance.
|
||||
*/
|
||||
public function __construct(string $fileName)
|
||||
{
|
||||
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||
parent::__construct(sprintf('A test in [%s] is missing its description. Please give every test a description.', $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresCommit extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(
|
||||
'Tia mode requires a repository with at least one commit, so the baseline it records can be anchored to a revision.',
|
||||
);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires at least one commit.',
|
||||
'',
|
||||
' A baseline is anchored to the revision it was recorded at, and this repository',
|
||||
' has none yet, so there is nothing to record against and nothing to compare a',
|
||||
' later run to.',
|
||||
'',
|
||||
' Commit once, then run again:',
|
||||
'',
|
||||
' <fg=yellow>git add . && git commit -m "Initial commit"</>',
|
||||
'',
|
||||
' Runs without <fg=yellow>--tia</> are unaffected.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ final class TiaRequiresPestTests extends RuntimeException implements ExceptionIn
|
||||
public function __construct(private readonly string $className, string $filename)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||
'Tia mode supports functional Pest tests only, but found the PHPUnit class [%s] in [%s]. Please convert it to a Pest test, or run without Tia.',
|
||||
$className,
|
||||
$filename,
|
||||
));
|
||||
|
||||
@@ -19,7 +19,7 @@ final class TiaRequiresRepositoryRoot extends RuntimeException implements Except
|
||||
public function __construct(private readonly string $subdirectoryPrefix)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||
'Tia mode requires the project root to be the git repository root, but this project sits in the subdirectory [%s] of a larger repository. Please give it its own repository to use Tia.',
|
||||
$this->subdirectoryPrefix,
|
||||
));
|
||||
}
|
||||
|
||||
+47
-205
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Attribute;
|
||||
use BadMethodCallException;
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
@@ -35,6 +34,7 @@ use Pest\Support\Reflection;
|
||||
use PHPUnit\Architecture\Elements\ObjectDescription;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
use ReflectionEnum;
|
||||
use ReflectionFunction;
|
||||
use ReflectionMethod;
|
||||
use ReflectionProperty;
|
||||
|
||||
@@ -60,19 +60,15 @@ final class Expectation
|
||||
use Retrievable;
|
||||
|
||||
/**
|
||||
* Creates a new expectation.
|
||||
*
|
||||
* @param TValue $value
|
||||
*/
|
||||
public function __construct(
|
||||
public mixed $value
|
||||
) {
|
||||
// ..
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expectation.
|
||||
*
|
||||
* @template TAndValue
|
||||
*
|
||||
* @param TAndValue $value
|
||||
@@ -84,8 +80,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expectation with the decoded JSON value.
|
||||
*
|
||||
* @return self<array<int|string, mixed>|bool>
|
||||
*/
|
||||
public function json(): Expectation
|
||||
@@ -103,8 +97,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the expectation value.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function dump(mixed ...$arguments): self
|
||||
@@ -118,9 +110,6 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the expectation value and end the script.
|
||||
*/
|
||||
public function dd(mixed ...$arguments): never
|
||||
{
|
||||
if (function_exists('dd')) {
|
||||
@@ -141,8 +130,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the expectation value when the result of the condition is truthy.
|
||||
*
|
||||
* @param (Closure(TValue): bool)|bool $condition
|
||||
* @return self<TValue>
|
||||
*/
|
||||
@@ -158,8 +145,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the expectation value when the result of the condition is falsy.
|
||||
*
|
||||
* @param (Closure(TValue): bool)|bool $condition
|
||||
* @return self<TValue>
|
||||
*/
|
||||
@@ -175,8 +160,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the expectation value to Ray along with all given arguments.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function ray(mixed ...$arguments): self
|
||||
@@ -189,8 +172,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the opposite expectation for the value.
|
||||
*
|
||||
* @return OppositeExpectation<TValue>
|
||||
*/
|
||||
public function not(): OppositeExpectation
|
||||
@@ -199,8 +180,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an expectation on each item of the iterable "value".
|
||||
*
|
||||
* @return EachExpectation<TValue>
|
||||
*/
|
||||
public function each(?callable $callback = null): EachExpectation
|
||||
@@ -219,8 +198,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows you to specify a sequential set of expectations for each item in a iterable "value".
|
||||
*
|
||||
* @template TSequenceValue
|
||||
*
|
||||
* @param (callable(self<TValue>, self<string|int>): void)|TSequenceValue ...$callbacks
|
||||
@@ -258,8 +235,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* If the subject matches one of the given "expressions", the expression callback will run.
|
||||
*
|
||||
* @template TMatchSubject of array-key
|
||||
*
|
||||
* @param (callable(): TMatchSubject)|TMatchSubject $subject
|
||||
@@ -298,8 +273,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the callback if the given "condition" is falsy.
|
||||
*
|
||||
* @param (callable(): bool)|bool $condition
|
||||
* @param callable(Expectation<TValue>): mixed $callback
|
||||
* @return self<TValue>
|
||||
@@ -314,8 +287,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the callback if the given "condition" is truthy.
|
||||
*
|
||||
* @param (callable(): bool)|bool $condition
|
||||
* @param callable(self<TValue>): mixed $callback
|
||||
* @return self<TValue>
|
||||
@@ -334,8 +305,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically calls methods on the class or creates a new higher order expectation.
|
||||
*
|
||||
* @param array<int, mixed> $parameters
|
||||
* @return Expectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue>
|
||||
*/
|
||||
@@ -350,7 +319,7 @@ final class Expectation
|
||||
|
||||
if (! is_object($this->value)) {
|
||||
throw new BadMethodCallException(sprintf(
|
||||
'Method "%s" does not exist in %s.',
|
||||
'Method [%s] does not exist in [%s].',
|
||||
$method,
|
||||
gettype($this->value)
|
||||
));
|
||||
@@ -361,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);
|
||||
@@ -379,8 +349,43 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expectation closure from the given name.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
private function getExpectationClosure(string $name): Closure
|
||||
@@ -402,8 +407,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically calls methods on the class without any arguments or creates a new higher order expectation.
|
||||
*
|
||||
* @return Expectation<TValue>|OppositeExpectation<TValue>|EachExpectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue|null>|TValue
|
||||
*/
|
||||
public function __get(string $name): mixed
|
||||
@@ -422,9 +425,6 @@ final class Expectation
|
||||
return $this->{$name}();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given expectation method exists.
|
||||
*/
|
||||
public static function hasMethod(string $name): bool
|
||||
{
|
||||
return method_exists(self::class, $name)
|
||||
@@ -432,17 +432,12 @@ final class Expectation
|
||||
|| self::hasExtend($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches any value.
|
||||
*/
|
||||
public function any(): Any
|
||||
{
|
||||
return new Any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target use the given dependencies.
|
||||
*
|
||||
* @param array<int, string>|string $targets
|
||||
*/
|
||||
public function toUse(array|string $targets): ArchExpectation
|
||||
@@ -450,9 +445,6 @@ final class Expectation
|
||||
return ToUse::make($this, $targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does have the given permissions
|
||||
*/
|
||||
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -463,9 +455,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to have line count less than the given number.
|
||||
*/
|
||||
public function toHaveLineCountLessThan(int $lines): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -476,9 +465,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target have all methods documented.
|
||||
*/
|
||||
public function toHaveMethodsDocumented(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -495,9 +481,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target have all properties documented.
|
||||
*/
|
||||
public function toHavePropertiesDocumented(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -515,9 +498,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target use the "declare(strict_types=1)" declaration.
|
||||
*/
|
||||
public function toUseStrictTypes(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -528,9 +508,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target uses strict equality.
|
||||
*/
|
||||
public function toUseStrictEquality(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -541,9 +518,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is final.
|
||||
*/
|
||||
public function toBeFinal(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -554,9 +528,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is readonly.
|
||||
*/
|
||||
public function toBeReadonly(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -567,9 +538,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is trait.
|
||||
*/
|
||||
public function toBeTrait(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -580,17 +548,11 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are traits.
|
||||
*/
|
||||
public function toBeTraits(): ArchExpectation
|
||||
{
|
||||
return $this->toBeTrait();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is abstract.
|
||||
*/
|
||||
public function toBeAbstract(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -602,8 +564,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target has a specific method.
|
||||
*
|
||||
* @param array<int, string>|string $method
|
||||
*/
|
||||
public function toHaveMethod(array|string $method): ArchExpectation
|
||||
@@ -613,14 +573,12 @@ final class Expectation
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod($method))) === count($methods),
|
||||
sprintf("to have method '%s'", implode("', '", $methods)),
|
||||
sprintf('to have method [%s]', implode('], [', $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target has a specific methods.
|
||||
*
|
||||
* @param array<int, string> $methods
|
||||
*/
|
||||
public function toHaveMethods(array $methods): ArchExpectation
|
||||
@@ -628,57 +586,36 @@ final class Expectation
|
||||
return $this->toHaveMethod($methods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHavePublicMethodsBesides(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHavePublicMethodsBesides']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHavePublicMethods(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHavePublicMethods']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHaveProtectedMethodsBesides(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHaveProtectedMethodsBesides']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHaveProtectedMethods(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHaveProtectedMethods']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHavePrivateMethodsBesides(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHavePrivateMethodsBesides']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHavePrivateMethods(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is cased correctly.
|
||||
*/
|
||||
public function toBeCasedCorrectly(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -711,9 +648,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is enum.
|
||||
*/
|
||||
public function toBeEnum(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -724,17 +658,11 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are enums.
|
||||
*/
|
||||
public function toBeEnums(): ArchExpectation
|
||||
{
|
||||
return $this->toBeEnum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is a class.
|
||||
*/
|
||||
public function toBeClass(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -745,17 +673,11 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are classes.
|
||||
*/
|
||||
public function toBeClasses(): ArchExpectation
|
||||
{
|
||||
return $this->toBeClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is interface.
|
||||
*/
|
||||
public function toBeInterface(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -766,30 +688,21 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are interfaces.
|
||||
*/
|
||||
public function toBeInterfaces(): ArchExpectation
|
||||
{
|
||||
return $this->toBeInterface();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to be subclass of the given class.
|
||||
*/
|
||||
public function toExtend(string $class): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && ($class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class)),
|
||||
sprintf("to extend '%s'", $class),
|
||||
sprintf('to extend [%s]', $class),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to be have a parent class.
|
||||
*/
|
||||
public function toExtendNothing(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -800,17 +713,12 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to use the given trait.
|
||||
*/
|
||||
public function toUseTrait(string $trait): ArchExpectation
|
||||
{
|
||||
return $this->toUseTraits($trait);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to use the given traits.
|
||||
*
|
||||
* @param array<int, string>|string $traits
|
||||
*/
|
||||
public function toUseTraits(array|string $traits): ArchExpectation
|
||||
@@ -852,9 +760,6 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to not implement any interfaces.
|
||||
*/
|
||||
public function toImplementNothing(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -866,8 +771,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to only implement the given interfaces.
|
||||
*
|
||||
* @param array<int, string>|string $interfaces
|
||||
*/
|
||||
public function toOnlyImplement(array|string $interfaces): ArchExpectation
|
||||
@@ -884,35 +787,27 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to have the given prefix.
|
||||
*/
|
||||
public function toHavePrefix(string $prefix): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
"to have prefix '{$prefix}'",
|
||||
"to have prefix [{$prefix}]",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to have the given suffix.
|
||||
*/
|
||||
public function toHaveSuffix(string $suffix): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
"to have suffix '{$suffix}'",
|
||||
"to have suffix [{$suffix}]",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to implement the given interfaces.
|
||||
*
|
||||
* @param array<int, string>|string $interfaces
|
||||
*/
|
||||
public function toImplement(array|string $interfaces): ArchExpectation
|
||||
@@ -928,8 +823,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target "only" use on the given dependencies.
|
||||
*
|
||||
* @param array<int, string>|string $targets
|
||||
*/
|
||||
public function toOnlyUse(array|string $targets): ArchExpectation
|
||||
@@ -937,33 +830,22 @@ final class Expectation
|
||||
return ToOnlyUse::make($this, $targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not use any dependencies.
|
||||
*/
|
||||
public function toUseNothing(): ArchExpectation
|
||||
{
|
||||
return ToUseNothing::make($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the source code of the given expectation target does not include suspicious characters.
|
||||
*/
|
||||
public function toHaveSuspiciousCharacters(): ArchExpectation
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toHaveSuspiciousCharacters']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toBeUsed(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['toBeUsed']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is used by the given targets.
|
||||
*
|
||||
* @param array<int, string>|string $targets
|
||||
*/
|
||||
public function toBeUsedIn(array|string $targets): ArchExpectation
|
||||
@@ -972,8 +854,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is "only" used by the given targets.
|
||||
*
|
||||
* @param array<int, string>|string $targets
|
||||
*/
|
||||
public function toOnlyBeUsedIn(array|string $targets): ArchExpectation
|
||||
@@ -981,17 +861,11 @@ final class Expectation
|
||||
return ToOnlyBeUsedIn::make($this, $targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is not used.
|
||||
*/
|
||||
public function toBeUsedInNothing(): ArchExpectation
|
||||
{
|
||||
return ToBeUsedInNothing::make($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is an invokable class.
|
||||
*/
|
||||
public function toBeInvokable(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -1003,8 +877,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation is iterable and contains snake_case keys.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toHaveSnakeCaseKeys(string $message = ''): self
|
||||
@@ -1027,8 +899,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation is iterable and contains kebab-case keys.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toHaveKebabCaseKeys(string $message = ''): self
|
||||
@@ -1051,8 +921,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation is iterable and contains camelCase keys.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toHaveCamelCaseKeys(string $message = ''): self
|
||||
@@ -1075,8 +943,6 @@ final class Expectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation is iterable and contains StudlyCase keys.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toHaveStudlyCaseKeys(string $message = ''): self
|
||||
@@ -1098,38 +964,26 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to have the given attribute.
|
||||
*/
|
||||
public function toHaveAttribute(string $attribute): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [],
|
||||
"to have attribute '{$attribute}'",
|
||||
"to have attribute [{$attribute}]",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target has a constructor method.
|
||||
*/
|
||||
public function toHaveConstructor(): ArchExpectation
|
||||
{
|
||||
return $this->toHaveMethod('__construct');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target has a destructor method.
|
||||
*/
|
||||
public function toHaveDestructor(): ArchExpectation
|
||||
{
|
||||
return $this->toHaveMethod('__destruct');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is a backed enum of given type.
|
||||
*/
|
||||
private function toBeBackedEnum(string $backingType): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
@@ -1143,33 +997,21 @@ final class Expectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are string backed enums.
|
||||
*/
|
||||
public function toBeStringBackedEnums(): ArchExpectation
|
||||
{
|
||||
return $this->toBeStringBackedEnum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are int backed enums.
|
||||
*/
|
||||
public function toBeIntBackedEnums(): ArchExpectation
|
||||
{
|
||||
return $this->toBeIntBackedEnum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is a string backed enum.
|
||||
*/
|
||||
public function toBeStringBackedEnum(): ArchExpectation
|
||||
{
|
||||
return $this->toBeBackedEnum('string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is an int backed enum.
|
||||
*/
|
||||
public function toBeIntBackedEnum(): ArchExpectation
|
||||
{
|
||||
return $this->toBeBackedEnum('int');
|
||||
|
||||
@@ -17,21 +17,14 @@ use function expect;
|
||||
*/
|
||||
final class EachExpectation
|
||||
{
|
||||
/**
|
||||
* Indicates if the expectation is the opposite.
|
||||
*/
|
||||
private bool $opposite = false;
|
||||
|
||||
/**
|
||||
* Creates an expectation on each item of the iterable "value".
|
||||
*
|
||||
* @param Expectation<TValue> $original
|
||||
*/
|
||||
public function __construct(private readonly Expectation $original) {}
|
||||
|
||||
/**
|
||||
* Creates a new expectation.
|
||||
*
|
||||
* @template TAndValue
|
||||
*
|
||||
* @param TAndValue $value
|
||||
@@ -43,8 +36,6 @@ final class EachExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the opposite expectation for the value.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function not(): self
|
||||
@@ -55,8 +46,6 @@ final class EachExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically calls methods on the class with the given arguments on each item.
|
||||
*
|
||||
* @param array<int|string, mixed> $arguments
|
||||
* @return self<TValue>
|
||||
*/
|
||||
@@ -73,8 +62,6 @@ final class EachExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically calls methods on the class without any arguments on each item.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
|
||||
@@ -25,19 +25,11 @@ final class HigherOrderExpectation
|
||||
*/
|
||||
private Expectation|EachExpectation $expectation;
|
||||
|
||||
/**
|
||||
* Indicates if the expectation is the opposite.
|
||||
*/
|
||||
private bool $opposite = false;
|
||||
|
||||
/**
|
||||
* Indicates if the expectation should reset the value.
|
||||
*/
|
||||
private bool $shouldReset = false;
|
||||
|
||||
/**
|
||||
* Creates a new higher order expectation.
|
||||
*
|
||||
* @param Expectation<TOriginalValue> $original
|
||||
* @param TValue $value
|
||||
*/
|
||||
@@ -47,8 +39,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the opposite expectation for the value.
|
||||
*
|
||||
* @return self<TOriginalValue, TValue>
|
||||
*/
|
||||
public function not(): self
|
||||
@@ -59,8 +49,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Expectation.
|
||||
*
|
||||
* @template TExpectValue
|
||||
*
|
||||
* @param TExpectValue $value
|
||||
@@ -72,8 +60,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expectation.
|
||||
*
|
||||
* @template TExpectValue
|
||||
*
|
||||
* @param TExpectValue $value
|
||||
@@ -85,9 +71,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope an expectation callback to the current value in
|
||||
* the HigherOrderExpectation chain.
|
||||
*
|
||||
* @param Closure(Expectation<TValue>): void $expectation
|
||||
* @return HigherOrderExpectation<TOriginalValue, TOriginalValue>
|
||||
*/
|
||||
@@ -99,8 +82,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expectation with the decoded JSON value.
|
||||
*
|
||||
* @return self<TOriginalValue, array<string|int, mixed>|bool>
|
||||
*/
|
||||
public function json(): self
|
||||
@@ -109,8 +90,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically calls methods on the class with the given arguments.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
@@ -125,8 +104,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Accesses properties in the value or in the expectation.
|
||||
*
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
@@ -145,9 +122,6 @@ final class HigherOrderExpectation
|
||||
return $this->performAssertion($name, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the original expectation has the given method name.
|
||||
*/
|
||||
private function expectationHasMethod(string $name): bool
|
||||
{
|
||||
if (method_exists($this->original, $name)) {
|
||||
@@ -161,8 +135,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the applicable value based on the current reset condition.
|
||||
*
|
||||
* @return TOriginalValue|TValue
|
||||
*/
|
||||
private function getValue(): mixed
|
||||
@@ -171,8 +143,6 @@ final class HigherOrderExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the given assertion with the current expectation.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, TValue>
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Expectations;
|
||||
|
||||
use Attribute;
|
||||
use Pest\Arch\Contracts\ArchExpectation;
|
||||
use Pest\Arch\Expectations\Targeted;
|
||||
use Pest\Arch\Expectations\ToBeUsedIn;
|
||||
@@ -38,15 +37,11 @@ use stdClass;
|
||||
final readonly class OppositeExpectation
|
||||
{
|
||||
/**
|
||||
* Creates a new opposite expectation.
|
||||
*
|
||||
* @param Expectation<TValue> $original
|
||||
*/
|
||||
public function __construct(private Expectation $original) {}
|
||||
|
||||
/**
|
||||
* Asserts that the value array not has the provided $keys.
|
||||
*
|
||||
* @param array<int, int|string|array<int-string, mixed>> $keys
|
||||
* @return Expectation<TValue>
|
||||
*/
|
||||
@@ -70,8 +65,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not use any of the given dependencies.
|
||||
*
|
||||
* @param array<int, string>|string $targets
|
||||
*/
|
||||
public function toUse(array|string $targets): ArchExpectation
|
||||
@@ -84,9 +77,6 @@ final readonly class OppositeExpectation
|
||||
), is_string($targets) ? [$targets] : $targets));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have the given permissions
|
||||
*/
|
||||
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -100,17 +90,11 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHaveLineCountLessThan(): ArchExpectation
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['not', 'toHaveLineCountLessThan']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHaveMethodsDocumented(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -130,9 +114,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toHavePropertiesDocumented(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -153,9 +134,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not use the "declare(strict_types=1)" declaration.
|
||||
*/
|
||||
public function toUseStrictTypes(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -169,9 +147,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not use the strict equality operator.
|
||||
*/
|
||||
public function toUseStrictEquality(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -185,9 +160,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not final.
|
||||
*/
|
||||
public function toBeFinal(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -201,9 +173,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not readonly.
|
||||
*/
|
||||
public function toBeReadonly(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -217,9 +186,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not trait.
|
||||
*/
|
||||
public function toBeTrait(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -233,17 +199,11 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are not traits.
|
||||
*/
|
||||
public function toBeTraits(): ArchExpectation
|
||||
{
|
||||
return $this->toBeTrait();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not abstract.
|
||||
*/
|
||||
public function toBeAbstract(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -258,8 +218,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have a specific method.
|
||||
*
|
||||
* @param array<int, string>|string $method
|
||||
*/
|
||||
public function toHaveMethod(array|string $method): ArchExpectation
|
||||
@@ -280,9 +238,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have suspicious characters.
|
||||
*/
|
||||
public function toHaveSuspiciousCharacters(): ArchExpectation
|
||||
{
|
||||
if (! class_exists(Spoofchecker::class)) {
|
||||
@@ -303,8 +258,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have the given methods.
|
||||
*
|
||||
* @param array<int, string> $methods
|
||||
*/
|
||||
public function toHaveMethods(array $methods): ArchExpectation
|
||||
@@ -313,8 +266,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the public methods besides the given methods.
|
||||
*
|
||||
* @param array<int, string>|string $methods
|
||||
*/
|
||||
public function toHavePublicMethodsBesides(array|string $methods): ArchExpectation
|
||||
@@ -345,22 +296,17 @@ final readonly class OppositeExpectation
|
||||
},
|
||||
$methods === []
|
||||
? 'not to have public methods'
|
||||
: sprintf("not to have public methods besides '%s'", implode("', '", $methods)),
|
||||
: sprintf('not to have public methods besides [%s]', implode('], [', $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the public methods.
|
||||
*/
|
||||
public function toHavePublicMethods(): ArchExpectation
|
||||
{
|
||||
return $this->toHavePublicMethodsBesides([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the protected methods besides the given methods.
|
||||
*
|
||||
* @param array<int, string>|string $methods
|
||||
*/
|
||||
public function toHaveProtectedMethodsBesides(array|string $methods): ArchExpectation
|
||||
@@ -391,22 +337,17 @@ final readonly class OppositeExpectation
|
||||
},
|
||||
$methods === []
|
||||
? 'not to have protected methods'
|
||||
: sprintf("not to have protected methods besides '%s'", implode("', '", $methods)),
|
||||
: sprintf('not to have protected methods besides [%s]', implode('], [', $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the protected methods.
|
||||
*/
|
||||
public function toHaveProtectedMethods(): ArchExpectation
|
||||
{
|
||||
return $this->toHaveProtectedMethodsBesides([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the private methods besides the given methods.
|
||||
*
|
||||
* @param array<int, string>|string $methods
|
||||
*/
|
||||
public function toHavePrivateMethodsBesides(array|string $methods): ArchExpectation
|
||||
@@ -437,22 +378,16 @@ final readonly class OppositeExpectation
|
||||
},
|
||||
$methods === []
|
||||
? 'not to have private methods'
|
||||
: sprintf("not to have private methods besides '%s'", implode("', '", $methods)),
|
||||
: sprintf('not to have private methods besides [%s]', implode('], [', $methods)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the private methods.
|
||||
*/
|
||||
public function toHavePrivateMethods(): ArchExpectation
|
||||
{
|
||||
return $this->toHavePrivateMethodsBesides([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not enum.
|
||||
*/
|
||||
public function toBeEnum(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -466,17 +401,11 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are not enums.
|
||||
*/
|
||||
public function toBeEnums(): ArchExpectation
|
||||
{
|
||||
return $this->toBeEnum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets is not class.
|
||||
*/
|
||||
public function toBeClass(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -490,17 +419,11 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are not classes.
|
||||
*/
|
||||
public function toBeClasses(): ArchExpectation
|
||||
{
|
||||
return $this->toBeClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not interface.
|
||||
*/
|
||||
public function toBeInterface(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -514,17 +437,11 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are not interfaces.
|
||||
*/
|
||||
public function toBeInterfaces(): ArchExpectation
|
||||
{
|
||||
return $this->toBeInterface();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to be not subclass of the given class.
|
||||
*/
|
||||
public function toExtend(string $class): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -533,14 +450,11 @@ final readonly class OppositeExpectation
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isSubclassOf($class),
|
||||
sprintf("not to extend '%s'", $class),
|
||||
sprintf('not to extend [%s]', $class),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to be not have any parent class.
|
||||
*/
|
||||
public function toExtendNothing(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -554,17 +468,12 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to use the given trait.
|
||||
*/
|
||||
public function toUseTrait(string $trait): ArchExpectation
|
||||
{
|
||||
return $this->toUseTraits($trait);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to use the given traits.
|
||||
*
|
||||
* @param array<int, string>|string $traits
|
||||
*/
|
||||
public function toUseTraits(array|string $traits): ArchExpectation
|
||||
@@ -583,8 +492,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to implement the given interfaces.
|
||||
*
|
||||
* @param array<int, string>|string $interfaces
|
||||
*/
|
||||
public function toImplement(array|string $interfaces): ArchExpectation
|
||||
@@ -602,9 +509,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to not implement any interfaces.
|
||||
*/
|
||||
public function toImplementNothing(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -618,17 +522,11 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toOnlyImplement(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['not', 'toOnlyImplement']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to not have the given prefix.
|
||||
*/
|
||||
public function toHavePrefix(string $prefix): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -637,14 +535,11 @@ final readonly class OppositeExpectation
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
"not to have prefix '{$prefix}'",
|
||||
"not to have prefix [{$prefix}]",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target to not have the given suffix.
|
||||
*/
|
||||
public function toHaveSuffix(string $suffix): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -653,30 +548,21 @@ final readonly class OppositeExpectation
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
"not to have suffix '{$suffix}'",
|
||||
"not to have suffix [{$suffix}]",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toOnlyUse(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['not', 'toOnlyUse']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not supported.
|
||||
*/
|
||||
public function toUseNothing(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['not', 'toUseNothing']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is not used.
|
||||
*/
|
||||
public function toBeUsed(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -686,8 +572,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is not used by any of the given targets.
|
||||
*
|
||||
* @param array<int, string>|string $targets
|
||||
*/
|
||||
public function toBeUsedIn(array|string $targets): ArchExpectation
|
||||
@@ -705,17 +589,11 @@ final readonly class OppositeExpectation
|
||||
throw InvalidExpectation::fromMethods(['not', 'toOnlyBeUsedIn']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is not used.
|
||||
*/
|
||||
public function toBeUsedInNothing(): void
|
||||
{
|
||||
throw InvalidExpectation::fromMethods(['not', 'toBeUsedInNothing']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation dependency is not an invokable class.
|
||||
*/
|
||||
public function toBeInvokable(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -729,9 +607,6 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target not to have the given attribute.
|
||||
*/
|
||||
public function toHaveAttribute(string $attribute): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -740,14 +615,12 @@ final readonly class OppositeExpectation
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getAttributes($attribute) === [],
|
||||
"to not have attribute '{$attribute}'",
|
||||
"to not have attribute [{$attribute}]",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dynamic method calls into the original expectation.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return Expectation<TValue>|Expectation<mixed>|never
|
||||
*/
|
||||
@@ -768,8 +641,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dynamic properties gets into the original expectation.
|
||||
*
|
||||
* @return Expectation<TValue>|Expectation<mixed>|never
|
||||
*/
|
||||
public function __get(string $name): Expectation
|
||||
@@ -788,8 +659,6 @@ final readonly class OppositeExpectation
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new expectation failed exception with a nice readable message.
|
||||
*
|
||||
* @param array<int, mixed>|string $arguments
|
||||
*/
|
||||
public function throwExpectationFailedException(string $name, array|string $arguments = []): never
|
||||
@@ -806,25 +675,16 @@ final readonly class OppositeExpectation
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have a constructor method.
|
||||
*/
|
||||
public function toHaveConstructor(): ArchExpectation
|
||||
{
|
||||
return $this->toHaveMethod('__construct');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target does not have a destructor method.
|
||||
*/
|
||||
public function toHaveDestructor(): ArchExpectation
|
||||
{
|
||||
return $this->toHaveMethod('__destruct');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not a backed enum of given type.
|
||||
*/
|
||||
private function toBeBackedEnum(string $backingType): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
@@ -841,33 +701,21 @@ final readonly class OppositeExpectation
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are not string backed enums.
|
||||
*/
|
||||
public function toBeStringBackedEnums(): ArchExpectation
|
||||
{
|
||||
return $this->toBeStringBackedEnum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation targets are not int backed enums.
|
||||
*/
|
||||
public function toBeIntBackedEnums(): ArchExpectation
|
||||
{
|
||||
return $this->toBeIntBackedEnum();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not a string backed enum.
|
||||
*/
|
||||
public function toBeStringBackedEnum(): ArchExpectation
|
||||
{
|
||||
return $this->toBeBackedEnum('string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is not an int backed enum.
|
||||
*/
|
||||
public function toBeIntBackedEnum(): ArchExpectation
|
||||
{
|
||||
return $this->toBeBackedEnum('int');
|
||||
|
||||
@@ -8,24 +8,12 @@ use Pest\Support\HigherOrderMessageCollection;
|
||||
|
||||
trait HigherOrderable
|
||||
{
|
||||
/**
|
||||
* The higher order messages that are chainable.
|
||||
*/
|
||||
public HigherOrderMessageCollection $chains;
|
||||
|
||||
/**
|
||||
* The higher order messages that are "factory" proxyable.
|
||||
*/
|
||||
public HigherOrderMessageCollection $factoryProxies;
|
||||
|
||||
/**
|
||||
* The higher order messages that are proxyable.
|
||||
*/
|
||||
public HigherOrderMessageCollection $proxies;
|
||||
|
||||
/**
|
||||
* Boot the higher order properties.
|
||||
*/
|
||||
private function bootHigherOrderable(): void
|
||||
{
|
||||
$this->chains = new HigherOrderMessageCollection;
|
||||
|
||||
@@ -30,29 +30,21 @@ final class TestCaseFactory
|
||||
use HigherOrderable;
|
||||
|
||||
/**
|
||||
* The list of attributes.
|
||||
*
|
||||
* @var array<int, Attribute>
|
||||
*/
|
||||
public array $attributes = [];
|
||||
|
||||
/**
|
||||
* The FQN of the Test Case class.
|
||||
*
|
||||
* @var class-string
|
||||
*/
|
||||
public string $class = TestCase::class;
|
||||
|
||||
/**
|
||||
* The list of class methods.
|
||||
*
|
||||
* @var array<string, TestCaseMethodFactory>
|
||||
*/
|
||||
public array $methods = [];
|
||||
|
||||
/**
|
||||
* The list of class traits.
|
||||
*
|
||||
* @var array <int, class-string>
|
||||
*/
|
||||
public array $traits = [
|
||||
@@ -60,14 +52,8 @@ final class TestCaseFactory
|
||||
Concerns\Expectable::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The namespace for the test case, overrides the path-based namespace when set.
|
||||
*/
|
||||
public ?string $namespace = null;
|
||||
|
||||
/**
|
||||
* Creates a new Factory instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $filename
|
||||
) {
|
||||
@@ -84,14 +70,11 @@ final class TestCaseFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Test Case class using a runtime evaluate.
|
||||
*
|
||||
* @param array<string, TestCaseMethodFactory> $methods
|
||||
*/
|
||||
public function evaluate(string $filename, array $methods): void
|
||||
{
|
||||
if ('\\' === DIRECTORY_SEPARATOR) {
|
||||
// In case Windows, strtolower drive name, like in UsesCall.
|
||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||
}
|
||||
|
||||
@@ -114,11 +97,8 @@ final class TestCaseFactory
|
||||
|
||||
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
|
||||
|
||||
// Strip out any %-encoded octets.
|
||||
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
|
||||
// Remove escaped quote sequences (maintain namespace)
|
||||
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
|
||||
// Limit to Unicode letters and numbers.
|
||||
$relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath);
|
||||
|
||||
$classFQN = 'P\\'.$relativePath;
|
||||
@@ -190,16 +170,13 @@ final class TestCaseFactory
|
||||
eval($classCode);
|
||||
} catch (ParseError $caught) {
|
||||
throw new RuntimeException(sprintf(
|
||||
"Unable to create test case for test file at %s. \n %s",
|
||||
"Unable to create test case for test file at [%s]. \n %s",
|
||||
$filename,
|
||||
$classCode
|
||||
), 1, $caught);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given Method to the Test Case.
|
||||
*/
|
||||
public function addMethod(TestCaseMethodFactory $method): void
|
||||
{
|
||||
if ($method->description === null) {
|
||||
@@ -233,9 +210,6 @@ final class TestCaseFactory
|
||||
$this->methods[$method->description] = $method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a test case has a method.
|
||||
*/
|
||||
public function hasMethod(string $methodName): bool
|
||||
{
|
||||
foreach ($this->methods as $method) {
|
||||
@@ -251,9 +225,6 @@ final class TestCaseFactory
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a Method by the given name.
|
||||
*/
|
||||
public function getMethod(string $methodName): TestCaseMethodFactory
|
||||
{
|
||||
foreach ($this->methods as $method) {
|
||||
@@ -266,6 +237,6 @@ final class TestCaseFactory
|
||||
}
|
||||
}
|
||||
|
||||
throw ShouldNotHappen::fromMessage(sprintf('Method %s not found.', $methodName));
|
||||
throw ShouldNotHappen::fromMessage(sprintf('Method [%s] not found.', $methodName));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user