Compare commits

..

180 Commits

Author SHA1 Message Date
nuno maduro 208f447a10 chore: style 2026-08-12 15:29:21 +01:00
Daniel Polito 6b7ca13660 feat: allow configuring the TIA directory (#1787)
* feat: allow configuring the TIA directory

* refactor: simplify TIA directory resolution

* fix: bootstrap TIA state after configuration

* chore: apply Rector to TIA storage test

* fix: stabilize checks on pull requests

* fix: align TIA fixture branch resolution

* fix: preserve success snapshot formatting

* fix: order TIA storage snapshot

* chore: keep TIA directory changes scoped
2026-08-12 14:53:45 +01:00
nuno maduro bf7c6b3134 fix(tia): casing issues 2026-08-11 01:47:14 +01:00
Caleb White 617226ca01 fix: fail missing snapshots in CI (#1842)
Prevent snapshot assertions from silently creating missing snapshots during CI runs unless --update-snapshots is enabled.
2026-08-11 01:02:44 +01:00
nuno maduro 375b92e93b chore: updates sponsors 2026-08-10 11:25:58 +01:00
nuno maduro e0e00ca748 chore: style 2026-08-10 11:12:08 +01:00
nuno maduro d70ecb873e chore: skip --tia on --mutate 2026-08-10 11:07:46 +01:00
nuno maduro 638932ddb5 chore: updates snapshots 2026-08-10 10:22:36 +01:00
nuno maduro ac48185736 chore: upgrades to phpunit:13.3 2026-08-10 10:16:30 +01:00
nuno maduro 2e4f8ad56f chore: fixes snapshots 2026-08-07 15:43:17 +01:00
nuno maduro 8c554a81e2 chore: refactors exceptions 2026-08-07 15:26:55 +01:00
nuno maduro 42d9b777bf chore: coding style changes 2026-08-07 14:52:56 +01:00
nuno maduro eb88513baf fix: livewire 2026-08-07 14:27:26 +01:00
nuno maduro 7b6415ccd0 fix 2026-08-07 03:15:22 +01:00
nuno maduro a5d726b678 release: 5.0.4 2026-08-07 03:00:25 +01:00
nuno maduro 93223caa56 chore: fixes ci 2026-08-07 03:00:19 +01:00
nuno maduro d0572ce138 wip 2026-08-07 02:26:45 +01:00
nuno maduro b0d1fc589d refactor 2026-08-07 02:12:24 +01:00
nuno maduro 5a4f124199 wip 2026-08-07 02:06:28 +01:00
nuno maduro c3ceb7d0c7 qwd 2026-08-07 02:00:58 +01:00
nuno maduro 16d1208344 wip 2026-08-07 01:58:41 +01:00
nuno maduro 1cebc84c2d wip 2026-08-07 01:55:51 +01:00
nuno maduro 1f79660add wip 2026-08-07 01:55:34 +01:00
nuno maduro 37bdf60a1c wip 2026-08-07 01:41:32 +01:00
nuno maduro cc7acfe485 wdq 2026-08-07 01:11:53 +01:00
nuno maduro 7bfb2a6185 chore: upgrade snapshots 2026-08-06 22:24:26 +01:00
nuno maduro ca56ca5216 dw 2026-08-06 22:19:47 +01:00
nuno maduro b49ba062d5 wq 2026-08-06 22:05:15 +01:00
nuno maduro 09699847a2 wqd 2026-08-06 20:53:09 +01:00
nuno maduro 545d0c2784 wip 2026-08-06 20:48:22 +01:00
nuno maduro b795af3b1b wip 2026-08-06 17:52:04 +01:00
nuno maduro a40cc6bc0e more tests 2026-08-06 16:35:03 +01:00
nuno maduro 4d3d0105b7 chore: style 2026-08-06 16:02:36 +01:00
nuno maduro e90e4f70fc wip 2026-08-05 20:20:35 +01:00
nuno maduro d112582857 wip 2026-08-05 19:58:57 +01:00
nuno maduro 9f3c4e1e82 fix 2026-08-05 18:51:49 +01:00
nuno maduro 71f39366c2 wip 2026-08-05 18:21:34 +01:00
nuno maduro bfd5b75677 fix 2026-08-05 16:49:35 +01:00
nuno maduro 411b9954b5 wip 2026-08-05 16:18:03 +01:00
nuno maduro db70017cb2 wip 2026-08-05 03:30:50 +01:00
nuno maduro 86adaedbbd fix: tia filtered 2026-08-04 23:30:03 +01:00
nuno maduro 92c7677c6e fix: don't allow invalid class names 2026-08-04 20:46:21 +01:00
nuno maduro 872f0a50c2 fix(tia): replayable scenarios running afterEach 2026-08-04 20:19:12 +01:00
nuno maduro 668809bc75 chore: adds missing test 2026-08-04 19:28:58 +01:00
nuno maduro 8d8f45c843 chore: bump deps 2026-08-04 19:28:52 +01:00
nuno maduro 086b3e9107 chore: ai instructions 2026-08-04 19:01:16 +01:00
nuno maduro 43fe26f324 chore: bumps deps 2026-08-04 19:01:08 +01:00
nuno maduro 865c1e5113 ci: snapshots 2026-08-04 18:51:04 +01:00
stephanbouman 19eed8d581 Fixed typos (#1819)
* Fixed typo in message

* Fixed typo in message
2026-08-03 11:29:03 -04:00
Danny de Wit ad1850b110 fix: keep TableExtractor table names as strings (#1794)
PHP coerces numeric-string array keys to ints, so collecting table names in
$tables[$name] and returning array_keys() can yield ints. That breaks the
declared list<string> and throws a TypeError in Recorder::linkTable(string).

Standard SQL like substring(x FROM 1 FOR 10) is enough to trigger it, since
the numeric operand matches the FROM pattern.

Closes #1793

Co-authored-by: Danny de Wit <d.dewit@fenceweb.com>
2026-08-02 22:50:47 -04:00
nuno maduro 585de259a2 chore: style 2026-08-02 22:26:34 -04:00
nuno maduro 953664dce0 release: v5.0.3 2026-08-02 22:24:32 -04:00
nuno maduro 1dd959848c chore: updates snapshots 2026-08-02 22:24:24 -04:00
Jeffrey van Hees 047753c836 fix: key Tia results per dataset row (#1799)
The result cache was keyed `Class::method` on both sides, so every row of a
`->with()` test shared one entry and the last writer won. On replay that single
status was handed to every row.

`TestMethod::id()` already appends `#dataSetName`, and `TestCase` reaches the
same value object through `valueObjectForEvents()`, so both sides can use it.
2026-08-02 21:43:15 -04:00
nuno maduro b63626a94d docs: adjusts readme
Signed-off-by: nuno maduro <enunomaduro@gmail.com>
2026-07-30 11:53:41 -04:00
nuno maduro ae080becd6 chore: style 2026-07-29 15:27:11 -04:00
nuno maduro aa86304794 release: v5.0.2 2026-07-29 14:59:13 -04:00
nuno maduro 75963ea973 chore: bumps deps 2026-07-29 14:59:03 -04:00
ohnotnow 5d0ceb1a5d Check for pcov being enabled for tia (#1779)
* Check for pcov being enabled for tia

* Delete tests/Features/TiaDriverGate.php

Signed-off-by: nuno maduro <enunomaduro@gmail.com>

---------

Signed-off-by: nuno maduro <enunomaduro@gmail.com>
Co-authored-by: Ohffs <ohffsnotnow@gmail.com>
Co-authored-by: nuno maduro <enunomaduro@gmail.com>
2026-07-29 14:28:38 -04:00
talaridisTh 6f0c0536a4 feat: document the tia options in pest --help (#1780) 2026-07-29 14:11:17 -04:00
Pushpak Chhajed 6b2cd358e8 Add Laravel Boost skill for Pest 5 testing (#1775) 2026-07-28 13:28:46 -04:00
Than Tibbetts e09e1be76d Update Pest v5 announcement link in README (#1773)
Signed-off-by: Than Tibbetts <than@me.com>
2026-07-28 12:21:33 -04:00
dependabot[bot] 759ac63139 build(deps): bump actions/checkout in the github-actions group (#1768)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 01:48:21 +01:00
nuno maduro 333f469efb adjustments
Signed-off-by: nuno maduro <enunomaduro@gmail.com>
2026-07-23 00:25:58 +01:00
nuno maduro c4ec925aa5 Revise Pest description in README.md
Updated the description of Pest to include AI agents.

Signed-off-by: nuno maduro <enunomaduro@gmail.com>
2026-07-22 22:15:30 +01:00
nuno maduro 5c8cfed483 docs: sync sponsors with pestphp.com
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:58:54 +01:00
nuno maduro 962510b7e3 chore: adjusts snapshots 2026-07-21 01:14:59 +01:00
Punyapal Shah e9c268a584 feat: validation expectations (#1762)
* feat: add validation expectations for IP, MAC, hostname, domain, base64, and hexadecimal

* improvements

* improvements
2026-07-20 11:21:57 +01:00
nuno maduro fa91fcf744 fix: coverage merger 2026-07-19 16:45:06 +01:00
nuno maduro c6073511a2 chore: fixes test printer 2026-07-19 13:59:31 +01:00
nuno maduro b5e1b83bb4 fix: cache results on parallel 2026-07-19 12:29:33 +01:00
nuno maduro 1c11595504 chore: moves to coding stlye 2026-07-19 03:41:57 +01:00
nuno maduro cd0e921158 chore: style 2026-07-19 00:42:29 +01:00
nuno maduro f8c4c061f8 fixes snapshots 2026-07-18 03:12:47 +01:00
nuno maduro 81eacd79fd fix: tia engine correctness issues
- keep table/Inertia/database link-tracking alive in piggyback-coverage
  recording, and merge link-only edges into the piggybacked edge set
- route migration changes through the watch-pattern fallback when the
  graph has no recorded table usage
- re-run cached failures whose test file cannot be located, downgrading
  filtered runs to full replay instead of silently skipping them
- align non-filtered replay with the failOn*/displayDetailsOn* rerun policy
- resolve anonymous index Blade components (<x-card> from card/index.blade.php)
  and stop swallowing components whose static usage walk selected no tests
- parse CTE/REPLACE queries and schema-qualified identifiers in TableExtractor
- scope-filter the xdebug recording path like pcov
- ignore in-flight .tmp files in FileState::keysWithPrefix and delete
  unreadable worker partials
- vite deps helper: resolve rolldown through rolldown-vite installs and
  degrade gracefully, honour vite.config resolve.alias (incl. the
  laravel-vite-plugin '@' default), resolve .vue/.svelte/.mts/.cts
  extensions, and stop memoizing cycle-tainted transitive sets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:50:20 +01:00
nuno maduro 1ef680c75d fix: tia 2026-07-18 02:28:29 +01:00
nuno maduro 820fa08313 fix: package lock fingerprint 2026-07-18 01:10:01 +01:00
nuno maduro 9e79e491e2 Merge branch '4.x' into 5.x 2026-07-17 16:38:01 +01:00
nuno maduro 1b83b8fcda release: v4.7.6 2026-07-17 16:33:29 +01:00
nuno maduro ff4aba2ffa chore: updates snapshots 2026-07-17 14:32:33 +01:00
nuno maduro feaac1ab7e chore: fixes rector ignore 2026-07-17 14:32:27 +01:00
nuno maduro bbc6ada21e chore: bumps deps 2026-07-17 14:32:17 +01:00
Sonali dudhia 3927dbfcdf fix: report dataset provider errors as failing tests (#1749)
Errors raised while resolving a test's dataset (missing named dataset,
throwing dataset closure) previously crashed the whole run. Now the data
provider catches them, wraps them in a DatasetProviderError, and the test
method rethrows the original throwable so the affected test fails cleanly
with a non-zero exit code while other tests keep running.
2026-07-17 14:13:01 +01:00
nuno maduro b52bbb4cca chore: snapshots 2026-07-14 15:11:20 +01:00
nuno maduro 52819501eb chore: fixes tia when not running on root 2026-07-14 14:34:00 +01:00
nuno maduro 7b18287374 feat: evals configuration 2026-07-07 19:10:35 +01:00
nuno maduro 33e3bf2888 chore: moves to phpunit 13.2 2026-07-07 13:40:02 +01:00
nuno maduro 72ab506d7b Merge branch '4.x' into 5.x 2026-07-06 18:47:25 +01:00
nuno maduro 5dc49a71d6 release: v4.7.5 2026-07-06 18:06:29 +01:00
nuno maduro 1adb484d3f chore: bumps deps 2026-07-06 18:05:35 +01:00
nuno maduro aadf7e92a6 fix: escape generated test case filename (#1746)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:18:26 +01:00
nuno maduro df42b9ed7b chore: style 2026-07-06 13:17:11 +01:00
dependabot[bot] 632ae79c8b build(deps): bump actions/cache in the github-actions group (#1745)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 5.0.5 to 6.1.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 12:57:08 +01:00
nuno maduro b5fed82883 chore: style 2026-06-25 20:24:28 +01:00
nuno maduro 145e20db75 chore: merges 4.x 2026-06-25 20:20:20 +01:00
nuno maduro ee2e97e932 release: v4.7.4 2026-06-25 20:09:05 +01:00
nuno maduro b561236325 chore: bumps phpunit 2026-06-25 20:09:05 +01:00
dependabot[bot] e685179b7e Bump actions/checkout from 6.0.3 to 7.0.0 in the github-actions group (#1737)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 20:03:39 +01:00
Conor Murphy ac8feafdcc fix: guard Tia argv scanning against non-string args (#1736)
The Tia plugin scans the raw argv with str_starts_with() in
argumentPresent() and hasExplicitPathArgument(). In the parallel worker
path (bin/worker.php) the unserialized argv can contain an integer
--random-order-seed value as a separate element, which made
str_starts_with() throw:

    TypeError: str_starts_with(): Argument #1 ($haystack) must be of
    type string, int given

This is the same class of bug as #1206, which was only fixed in
HandleArguments; it resurfaced in the newer Tia plugin. Cast each argv
element to string before scanning, mirroring the #1206 fix.

Adds a regression test, and updates the parallel count assertion and the
success snapshot to account for it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:03:06 +01:00
Sonali dudhia b49cd150c9 feat: add toBeEmail expectation (#1735)
Adds `toBeEmail()` backed by `filter_var(FILTER_VALIDATE_EMAIL)`.
Mirrors the existing `toBeUrl()` pattern — no new dependencies, pure PHP.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:51:35 +01:00
Norby Baruani 5cfb4133bf [5.x] Make time-based sharding namespace-agnostic and forward --test-directory (#1677)
* refactor(shard): extract parseListTestsOutput for testability

* test(shard): characterize parseListTestsOutput current behavior

* fix(shard): parse any PHP FQCN namespace from --list-tests

* fix(shard): forward --test-directory to list-tests subprocess

* chore: lint + snapshot fixups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(shard): make extracted helpers private, test via reflection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* revert visual_snapshot_of_help_command_output

* revert visual_snapshot_of_help_command_output

* keep function removeParallelArguments

* strip --processes argument when building list-tests command

The removeParallelArguments method was not filtering --processes flags, causing the list-tests subprocess to fail when parallel execution was enabled. This prevented time-based sharding from working correctly with the --parallel option.

Now both --parallel/-p and --processes arguments are removed from the command used to enumerate tests, ensuring the subprocess runs successfully.

* test: re-add namespace-agnostic sharding tests

- 5.x merge kept describe()-style test file, dropped PR #1677 tests for parseListTestsOutput + buildListTestsCommand. Re-add them in matching style.
- Also fix removeParallelArguments test broken by merge: source array_values() + strips --processes, so expects ['bin/pest','tests/'].

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 13:09:30 +01:00
nuno maduro 3c8bae5f05 chore: bumps dependencies 2026-06-12 20:30:22 +01:00
nuno maduro b2998bc69e chore: updates snapshots 2026-06-12 20:30:16 +01:00
nuno maduro 3876093cd2 fix: missing array values 2026-06-12 20:22:47 +01:00
Moshe Brodsky 932f8bcc07 consistent sharding logic when no shards file (#1710) 2026-06-12 20:19:30 +01:00
oddvalue d393799d2a Optimize buildFilterArgument in Shard plugin for compact regex generation and add comprehensive tests (#1675) 2026-06-12 20:06:50 +01:00
nuno maduro 0d7814ca16 chore: update snapshots 2026-06-12 18:02:06 +01:00
flap152 8467c64c22 fix: popArgument drops duplicate arguments breaking --parallel --exclude-gropup= (#1674)
* fix: popArgument drops duplicate arguments breaking --parallel multi-exclude-group

Fixes #1437

* fix: ensure popArgument handles duplicate arguments

* fix: update expected test results and snapshots after rebase

---------

Signed-off-by: nuno maduro <enunomaduro@gmail.com>
Co-authored-by: nuno maduro <enunomaduro@gmail.com>
2026-06-12 17:58:37 +01:00
oddvalue 97714a7088 Enforce filter length validation and add tests for Shard plugin (#1673) 2026-06-12 17:56:32 +01:00
nuno maduro afb582616d chore: points to 5.x 2026-06-12 07:29:53 +01:00
nuno maduro 15e9b6a507 chore: snapshots 2026-06-12 07:22:13 +01:00
nuno maduro 520ce29376 Merge branch '4.x' into 5.x 2026-06-12 07:22:01 +01:00
nuno maduro 87882a8561 fix: exit code 2026-06-12 06:57:27 +01:00
nuno maduro 77ef7e0df1 ci: bumps setup-php pinned commit 2026-06-12 06:51:58 +01:00
dependabot[bot] 12100dd901 Bump actions/checkout from 6.0.2 to 6.0.3 in the github-actions group (#1730)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 06:51:20 +01:00
nuno maduro 20c12d006e docs: fixes alt 2026-06-12 06:32:20 +01:00
nuno maduro 3aab02d2eb ci: fixes dd test 2026-06-11 10:50:24 +01:00
nuno maduro 553aac65e6 release: v4.7.3 2026-06-11 10:40:18 +01:00
Punyapal Shah be49a3ce18 Fix: dd (#1692)
* fix: update dd method to return never type

Co-authored-by: Copilot <copilot@github.com>

* fix: enhance var_dump calls to accept additional arguments

* fix: update dd method to handle paratest and collision printer environments

* test: add dd method tests for ExpectationFailedException in parallel mode

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-06-11 10:16:07 +01:00
Sonali dudhia 774a340400 feat: add toBeUlid assertion and isUlid validation method (#1726) 2026-06-11 10:14:00 +01:00
Chetan 04c9d41895 fix: update gitlab urls for issues and prs to match new format (#1728)
* fix: update gitlab urls for issues and prs to match new format

Added an optional host parameter to the gitlab method, defaulting to 'gitlab.com'. Updated the issues and prs URLs to include '/-/' in the path, which is the new format for GitLab URLs. This change ensures that the URLs generated for GitLab projects are correct and reflect the new structure of GitLab's URLs for issues and merge requests.

Host parameter allows users to specify a custom GitLab instance if they are using a self-hosted version of GitLab, while still maintaining the default behavior for users who are using gitlab.com.

* fix: update gitlab method to use hostname parameter correctly
2026-06-11 10:13:26 +01:00
nuno maduro dfb7b870af fix: retry output expected string 2026-06-11 10:12:41 +01:00
nuno maduro 37821e1108 chore: fixes output snapshots on team city 2026-06-11 10:12:32 +01:00
nuno maduro cd711a25d5 chore: bumps phpunit 2026-06-11 10:08:36 +01:00
nuno maduro eee60c9e11 fix: duplicated team city output 2026-06-11 10:08:27 +01:00
jp c40c8dbc24 [4.x] Fix Checks (#1709)
* fix(composer): set root version for feature-branch CI

pest-plugin-browser ^4.3.1 requires pestphp/pest ^4.4.5 on the root package.
Without an explicit version, Composer infers dev-<branch> on PR branches,
which fails composer update before tests run.

* set root version of composer

* fix indent
2026-06-06 01:23:08 +01:00
dependabot[bot] 2d93c9c373 Bump shivammathur/setup-php in the github-actions group (#1707)
Bumps the github-actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.0 to 2.37.1
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/accd6127cb78bee3e8082180cb391013d204ef9f...7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:55:17 +01:00
dependabot[bot] 3d5bba93f8 Bump shivammathur/setup-php in the github-actions group (#1706)
Bumps the github-actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.0 to 2.37.1
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/accd6127cb78bee3e8082180cb391013d204ef9f...7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:55:13 +01:00
nuno maduro e5ab3af05b ci: update dependabot config (add cooldown, single update entry) 2026-06-04 17:54:45 +01:00
nuno maduro 79bc7a8257 Merge branch '4.x' into 5.x 2026-06-01 07:09:57 +01:00
nuno maduro 40b88b62ef release: v4.7.2 2026-06-01 07:08:59 +01:00
nuno maduro e3361bc321 fix: don't ob_start with pao 2026-06-01 07:08:50 +01:00
nuno maduro fc48c1bd1e Merge branch '4.x' into 5.x 2026-06-01 06:33:35 +01:00
nuno maduro 92e76eb5ab ci: runs ci only against stable 2026-06-01 06:32:29 +01:00
nuno maduro da726beffc chore: merges 4.x 2026-06-01 06:28:44 +01:00
nuno maduro 4ef12b9aac Merge branch '4.x' into 5.x 2026-06-01 06:25:56 +01:00
nuno maduro bd22f478b8 chore: fixes issues with contracts and symfony 8.1 2026-06-01 06:24:42 +01:00
nuno maduro eeaac34cf6 release: v4.7.1 2026-06-01 05:42:12 +01:00
nuno maduro b9b07d8983 chore: bump dependencies 2026-06-01 05:42:03 +01:00
nuno maduro 6aa7d2f891 fix: better fatal exceptions reporting 2026-06-01 05:41:58 +01:00
nuno maduro 4d550cecfd Merge branch '4.x' into 5.x 2026-05-13 12:20:46 +01:00
nuno maduro 1c21a7647a chore: fixes types 2026-05-13 12:20:00 +01:00
nuno maduro d649de1988 chore: add security policy 2026-05-12 02:48:25 +01:00
nuno maduro 783ca4bcd6 chore(deps): limit dependabot to maintained branches (4.x + 5.x) 2026-05-12 02:34:08 +01:00
nuno maduro ba07497219 chore: enable Dependabot version updates for GitHub Actions (#1700) 2026-05-11 22:12:07 -03:00
nuno maduro 34695843b3 chore: pin GitHub Actions to commit SHAs (#1699)
* chore: pin GitHub Actions to commit SHAs

* chore: pin GitHub Actions to commit SHAs
2026-05-11 22:12:04 -03:00
nuno maduro 1ca021dea6 chore: pin GitHub Actions to commit SHAs (#1695)
* chore: pin GitHub Actions to commit SHAs

* chore: pin GitHub Actions to commit SHAs
2026-05-12 02:08:47 +01:00
nuno maduro d17be9decd types 2026-05-04 08:02:09 -03:00
nuno maduro b828ddcec7 chore: style 2026-05-04 07:38:50 -03:00
nuno maduro f859bb179d Merge branch '4.x' into 5.x 2026-05-04 07:38:40 -03:00
nuno maduro 18bbca748f Merge branch '4.x' into 5.x 2026-04-18 07:03:46 -07:00
nuno maduro f142aad8ad Merge branch '4.x' into 5.x 2026-04-17 19:35:53 -07:00
nuno maduro 74a28d4f5e fix: wrapper runner 2026-04-17 07:29:03 -07:00
nuno maduro 6053e15d00 Merge branch '4.x' into 5.x 2026-04-17 06:07:14 -07:00
nuno maduro 2d649d765f chore: adjusts tests 2026-04-11 01:54:13 +01:00
nuno maduro 4fb4908570 Merge branch '4.x' into 5.x 2026-04-10 22:37:24 +01:00
nuno maduro e63a886f98 Merge pull request #1661 from Avnsh1111/fix/opposite-expectation-truncated-message
fix: preserve full error message in not() expectation failures
2026-04-10 11:48:24 +01:00
nuno maduro 8dd650fd05 Merge branch '4.x' into 5.x 2026-04-09 21:39:15 +01:00
nuno maduro fbca346d7c fix: types 2026-04-07 14:40:55 +01:00
nuno maduro 3f13bca0f7 just in case 2026-04-07 14:37:13 +01:00
nuno maduro d3acb1c56a fix: coverage 2026-04-07 14:33:41 +01:00
avinash e601e6df31 fix: preserve full error message in not() expectation failures
When using not() expectations with custom error messages, the message
was truncated because throwExpectationFailedException() passed all
arguments through shortenedExport() which limits strings to ~40 chars.

Uses the full export() method for arguments instead of shortenedExport()
so custom error messages are displayed in their entirety.

Fixes #1533
2026-04-07 18:12:54 +05:30
nuno maduro 6fdbca1226 fix: parallel testing 2026-04-06 23:37:49 +01:00
nuno maduro 54359b895f Merge branch '4.x' into 5.x 2026-04-06 21:57:41 +01:00
nuno maduro 44c04bfce1 chore: bumps paratest 2026-04-06 14:41:38 +01:00
nuno maduro 271c680d3c Merge branch '4.x' into 5.x 2026-04-06 11:24:05 +01:00
nuno maduro 4a1d8d27b8 chore: bumps dependencies 2026-04-03 12:12:27 +01:00
nuno maduro 0f6924984c Merge branch '4.x' into 5.x 2026-04-03 12:02:36 +01:00
nuno maduro 668ca9f5de feat: adds pao 2026-04-02 15:45:13 +01:00
nuno maduro f659a45311 Merge branch '4.x' into 5.x 2026-03-21 13:20:25 +00:00
nuno maduro 12c1da29ee Merge branch '4.x' into 5.x 2026-03-10 21:21:24 +00:00
Nuno Maduro fa27c8daef chore: version 2026-02-17 17:52:40 +00:00
Nuno Maduro f0a08f0503 chore: missing types 2026-02-17 17:52:00 +00:00
Nuno Maduro 2c040c5b1f chore: style 2026-02-17 17:45:50 +00:00
Nuno Maduro a9ce1fd739 chore: code refactor 2026-02-17 17:45:34 +00:00
Nuno Maduro 3533356262 chore: updates snapshots 2026-02-17 17:44:56 +00:00
Nuno Maduro 4aa41d0b14 chore: bumps dependencies 2026-02-17 17:41:38 +00:00
Nuno Maduro e4ed60085c chore: bumps dependencies 2026-02-17 17:18:45 +00:00
Nuno Maduro e2b119655d chore: point pestphp dependencies to ^5.0.0 2026-02-17 17:13:36 +00:00
Nuno Maduro fcf5baf0a9 chore: start preparing for pest 5.x 2026-02-17 16:55:03 +00:00
536 changed files with 12999 additions and 5855 deletions
+13
View File
@@ -0,0 +1,13 @@
# Security Policy
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
## Reporting a Vulnerability
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
All security vulnerabilities will be promptly addressed.
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
groups:
github-actions:
patterns:
- "*"
+11 -9
View File
@@ -2,7 +2,7 @@ name: Static Analysis
on:
push:
branches: [4.x]
branches: [5.x]
pull_request:
schedule:
- cron: '0 9 * * *'
@@ -24,16 +24,16 @@ jobs:
strategy:
fail-fast: true
matrix:
dependency-version: [prefer-lowest, prefer-stable]
dependency-version: [prefer-stable]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: 8.3
php-version: 8.4
tools: composer:v2
coverage: none
extensions: sockets
@@ -44,15 +44,17 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
static-php-8.3-${{ matrix.dependency-version }}-composer-
static-php-8.3-composer-
static-php-8.4-${{ matrix.dependency-version }}-composer-
static-php-8.4-composer-
- name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check
+8 -9
View File
@@ -2,7 +2,7 @@ name: Tests
on:
push:
branches: [4.x]
branches: [5.x]
pull_request:
schedule:
- cron: '0 9 * * *'
@@ -24,21 +24,18 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['7.4', '8.0']
php: ['8.3', '8.4', '8.5']
symfony: ['8.0']
php: ['8.4', '8.5']
dependency_version: [prefer-stable]
exclude:
- php: '8.3'
symfony: '8.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
@@ -51,7 +48,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
@@ -66,6 +63,8 @@ jobs:
- name: Install PHP dependencies
shell: bash
env:
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests
+1 -1
View File
@@ -1,7 +1,7 @@
.idea/*
.idea/codeStyleSettings.xml
.temp/*
composer.lock
/composer.lock
/vendor/
coverage.xml
.phpunit.result.cache
+34
View File
@@ -0,0 +1,34 @@
# CLAUDE.md
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
## Ask before testing
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
Once the user confirms:
```bash
composer test:unit # fast, excludes the visual group
composer test:integration # visual and snapshot tests
composer test # everything CI runs, in CI's order
composer update:snapshots # only when a test was added or removed
```
## TIA scenario tests
`tests/Features/Tia/*` scaffold a throwaway git project, run a real `pest` subprocess against it, and diff the TIA graph it wrote. They exist because TIA's contract is about what a run *writes* — replay, branch keys, and the COMPLETE / RESULTS-ONLY / HARD-SUPPRESSED tiers are invisible to ordinary assertions, and every case used to be measured by hand against a playground app.
Add one whenever a change touches branch resolution, replay, filtered mode, or the write tiers. How:
- `Project::make('master')` scaffolds; `seed('master')` writes a graph and sentinels every cached result (`time=9.999`, `assertions=42`) so any rewrite shows up.
- `$project->pest('--tia', …)` runs it; `$project->delta()` compares against that snapshot. `writtenCount()` is the discriminator — `0` means "replayed", not "wrote the same values". `mutateGraph()` bends one entry; overlays in `tests/Fixtures/Tia/overlays/<name>/` supply a different `tests/Pest.php`.
- Keep expectations driver-independent: a cold recording run needs pcov/xdebug and behaves differently without one. Seed a graph instead of recording one.
Run them by file (a directory argument finds nothing) or by `--filter`:
```bash
php bin/pest tests/Features/Tia/PartialRunWriteTier.php
```
+14 -22
View File
@@ -1,7 +1,7 @@
<p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
<p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
@@ -12,9 +12,9 @@
------
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
**Pest** is an elegant testing framework for PHP developers and AI agents.
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
- Follow the creator Nuno Maduro:
@@ -29,26 +29,18 @@
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
### Platinum Sponsors
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[Mailtrap](https://l.rw.rw/pestphp)**
- **[PhpStorm](https://jb.gg/nuno)**
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
- **[Tighten](https://tighten.com/?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)**
### Gold Sponsors
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
### Premium Sponsors
- [Zapiet](https://zapiet.com/?ref=pestphp)
- [Load Forge](https://loadforge.com/?ref=pestphp)
- [Route4Me](https://route4me.com/pt?ref=pestphp)
- [Nerdify](https://getnerdify.com/?ref=pestphp)
- [Akaunting](https://akaunting.com/?ref=pestphp)
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
- **[Pixel](https://wearepixel.com.au/?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)**.
+3 -3
View File
@@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v3 you should use the `3.x` branch instead.**
> **For Pest v4 you should use the `4.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
- Update the version number in [src/Pest.php](src/Pest.php)
- Run the tests locally using: `composer test`
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
+173 -19
View File
@@ -25,26 +25,72 @@ const PAGE_DIR_CANDIDATES = [
async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const path = projectRequire.resolve('rolldown')
let path = null
try { path = projectRequire.resolve('rolldown') } catch {}
if (path === null) {
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
// dependency of the vite package rather than a top-level install.
try {
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
path = viteRequire.resolve('rolldown')
} catch {}
}
if (path === null) return null
return await import(pathToFileURL(path).href)
}
export function stripJsonComments(raw) {
let out = ''
let inString = false
let quote = ''
let inLine = false
let inBlock = false
for (let i = 0; i < raw.length; i++) {
const c = raw[i]
const n = raw[i + 1]
if (inLine) {
if (c === '\n') { inLine = false; out += c }
continue
}
if (inBlock) {
if (c === '*' && n === '/') { inBlock = false; i++ }
continue
}
if (inString) {
out += c
if (c === '\\') { out += n ?? ''; i++; continue }
if (c === quote) inString = false
continue
}
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
if (c === '/' && n === '/') { inLine = true; i++; continue }
if (c === '/' && n === '*') { inBlock = true; i++; continue }
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
out += c
}
return out
}
async function readJsonWithComments(path) {
const raw = await readFile(path, 'utf8')
const stripped = raw
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
return JSON.parse(stripped)
return JSON.parse(stripJsonComments(raw))
}
async function loadAliasFromTsconfig() {
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(PROJECT_ROOT, name)
const p = join(projectRoot, name)
if (!existsSync(p)) continue
let cfg
try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
const paths = cfg?.compilerOptions?.paths ?? {}
for (const [key, targets] of Object.entries(paths)) {
if (!key.endsWith('/*')) continue
@@ -58,6 +104,74 @@ async function loadAliasFromTsconfig() {
return alias
}
const VITE_CONFIG_FILES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
'vite.config.cts',
]
function resolveAliasTarget(projectRoot, target) {
if (target.startsWith('/')) {
// Vite resolves a leading slash against the project root, unless the
// config author used a genuinely absolute path.
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
}
return resolve(projectRoot, target)
}
async function usesLaravelVitePlugin(projectRoot) {
const p = join(projectRoot, 'package.json')
if (!existsSync(p)) return false
try {
const pkg = JSON.parse(await readFile(p, 'utf8'))
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
} catch {
return false
}
}
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
const alias = {}
let source = null
for (const name of VITE_CONFIG_FILES) {
const p = join(projectRoot, name)
if (!existsSync(p)) continue
try { source = await readFile(p, 'utf8') } catch { continue }
break
}
if (source !== null) {
// The config is executable code we cannot import safely, so extract alias
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
// the value expression — covers `'@': '/resources/js'` as well as
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
// top-level comma; call arguments are kept via the paren group.
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
const key = m[1]
if (alias[key] !== undefined) continue
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
const target = paths.length > 0 ? paths[paths.length - 1] : null
if (!target || target.includes('*')) continue
alias[key] = resolveAliasTarget(projectRoot, target)
}
}
// laravel-vite-plugin registers '@' → resources/js by default.
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
alias['@'] = resolve(projectRoot, 'resources/js')
}
return alias
}
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
@@ -76,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) {
@@ -85,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
}
@@ -121,8 +254,16 @@ async function main() {
return
}
const { rolldown } = await loadRolldown()
const alias = await loadAliasFromTsconfig()
const loaded = await loadRolldown()
if (loaded === null) {
process.stdout.write('{}')
return
}
const { rolldown } = loaded
// The vite config is what the dev server actually resolves with — let it win.
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
const aliasKeys = Object.keys(alias)
const graph = new Map()
@@ -167,7 +308,7 @@ async function main() {
cwd: PROJECT_ROOT,
resolve: {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
},
transform: { jsx: 'preserve' },
treeshake: false,
@@ -192,6 +333,11 @@ async function main() {
stack.add(id)
const acc = new Set()
// A set computed while skipping an in-stack (cyclic) dependency is missing
// that dependency's subtree and must not be memoized — the ancestor call
// completes it for the current traversal, but a cached copy would leak the
// incomplete set into other pages' traversals.
let complete = true
const deps = graph.get(id)
if (deps) {
for (const dep of deps) {
@@ -200,13 +346,17 @@ async function main() {
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
acc.add(rel)
}
if (stack.has(dep)) continue
if (stack.has(dep)) {
complete = false
continue
}
const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r)
if (!transitiveCache.has(dep)) complete = false
}
}
stack.delete(id)
transitiveCache.set(id, acc)
if (complete) transitiveCache.set(id, acc)
return acc
}
@@ -230,10 +380,14 @@ async function main() {
process.stdout.write(JSON.stringify(payload))
}
try {
void pathToFileURL
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
const invokedDirectly = process.argv[1] !== undefined
&& import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
try {
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}
}
-1
View File
@@ -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);
+20 -17
View File
@@ -17,21 +17,20 @@
}
],
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"composer/xdebug-handler": "^3.0.5",
"nunomaduro/collision": "^8.9.4",
"php": "^8.4",
"brianium/paratest": "^7.24.0",
"nunomaduro/collision": "^8.9.5",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.24",
"symfony/process": "^7.4.8|^8.0.8"
"pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^5.0.2",
"pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^13.3.0",
"symfony/process": "^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.24",
"phpunit/phpunit": ">13.3.0",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@@ -49,6 +48,7 @@
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
"Tests\\": "tests/PHPUnit/"
},
"classmap": [
@@ -59,17 +59,19 @@
]
},
"require-dev": {
"mrpunyapal/peststan": "^0.2.9",
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
"psy/psysh": "^0.12.22"
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-browser": "^5.0.1",
"pestphp/pest-plugin-phpstan": "^5.0.2",
"pestphp/pest-plugin-rector": "^5.0.3",
"pestphp/pest-plugin-type-coverage": "^5.0.2",
"psy/psysh": "^0.12.24"
},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
"sort-packages": true,
"preferred-install": "dist",
"process-timeout": 0,
"allow-plugins": {
"pestphp/pest-plugin": true
}
@@ -93,7 +95,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);
/*
+13 -51
View File
@@ -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;
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/**
* @internal
*/
final readonly class ProgressPrinterOutput implements Printer
{
public function __construct(
private Printer $progressPrinter,
private Printer $outputPrinter,
) {}
public function print(string $buffer): void
{
if (
$buffer === "\n"
|| preg_match('/^ +$/', $buffer) === 1
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
) {
return;
}
match ($buffer) {
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
default => $this->outputPrinter->print($buffer),
};
}
public function flush(): void
{
$this->progressPrinter->flush();
$this->outputPrinter->flush();
}
}
+11 -113
View File
@@ -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();
}
}
+2 -34
View File
@@ -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;
@@ -90,11 +58,11 @@ final class TestSuiteLoader
(static function () use ($suiteClassFile) {
try {
include_once $suiteClassFile;
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
} catch (Throwable $e) {
Panic::with($e);
}
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
})();
$loadedClasses = array_values(
+119 -118
View File
@@ -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);
/*
@@ -43,7 +11,7 @@ declare(strict_types=1);
* file that was distributed with this source code.
*/
namespace Pest\Logging\TeamCity\Subscriber;
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
@@ -52,24 +20,21 @@ use ReflectionClass;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{
public function notify(Skipped $event): void
{
if (str_contains($event->message(), '__TODO__')) {
if ($event->message() === '__TODO__') {
$this->printTodoItem();
}
$this->logger()->testSkipped($event);
$this->printer()->testSkipped();
}
/**
* Prints a "T" to the standard PHPUnit output to indicate a todo item.
*/
private function printTodoItem(): void
{
$mirror = new ReflectionClass($this->printer());
$printerMirror = $mirror->getMethod('printProgress');
$printerMirror->invoke($this->printer(), 'T');
$printProgress = $mirror->getMethod('printProgress');
$printProgress->invoke($this->printer(), 'T');
}
}
@@ -1,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 -1
View File
@@ -1,7 +1,7 @@
includes:
- phpstan-baseline.neon
- phpstan-pest-extension.neon
- vendor/mrpunyapal/peststan/extension.neon
- vendor/pestphp/pest-plugin-phpstan/extension.neon
parameters:
level: 7
+2 -1
View File
@@ -18,8 +18,9 @@
<directory suffix=".php">./tests</directory>
<directory suffix=".php">./tests-external</directory>
<exclude>./tests/.snapshots</exclude>
<exclude>./tests/.tests</exclude>
<exclude>./tests/Fixtures/Inheritance</exclude>
<exclude>./tests/Fixtures/Suites</exclude>
<exclude>./tests/Fixtures/Tia</exclude>
</testsuite>
</testsuites>
<source>
+8
View File
@@ -0,0 +1,8 @@
{
"exclude": [
"tests/Fixtures/Suites"
],
"rules": {
"Pint/phpdoc_type_annotations_only": true
}
}
+33
View File
@@ -2,22 +2,55 @@
declare(strict_types=1);
use Pest\Rector\Rules\UseToMatchArrayRector;
use Pest\Rector\Set\PestSetList;
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
return RectorConfig::configure()
->withPaths([
__DIR__.'/src',
__DIR__.'/tests',
])
->withSets([
PestSetList::CODING_STYLE,
])
->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
__DIR__.'/tests/Fixtures/Arch',
__DIR__.'/tests/Fixtures/Suites',
ReturnNeverTypeRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class,
RemoveDuplicatedReturnSelfDocblockRector::class,
RemoveUselessUnionReturnDocblockRector::class,
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
],
UseToMatchArrayRector::class,
RemoveEmptyClassMethodRector::class => [
__DIR__.'/tests',
],
RemoveUnusedConstructorParamRector::class => [
__DIR__.'/tests',
],
RemoveUnusedPrivatePropertyRector::class => [
__DIR__.'/tests',
],
AddArrowFunctionReturnTypeRector::class => [
__DIR__.'/tests',
],
])
->withPreparedSets(
deadCode: true,
@@ -0,0 +1,196 @@
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
---
@php
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
@endphp
# Pest Testing 5
## Documentation
Use `search-docs` for detailed Pest 5 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
@boostsnippet("Basic Pest Test Example", "php")
it('is true', function () {
expect(true)->toBeTrue();
});
@endboostsnippet
### Running Tests
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
@boostsnippet("Pest Response Assertion", "php")
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
@endboostsnippet
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
@boostsnippet("Pest Dataset Example", "php")
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
@endboostsnippet
## Pest 5 Features
| Feature | Purpose |
|---------|---------|
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
| Time-Balanced Sharding | Split tests across CI shards by execution time |
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Architecture Testing | Enforce code conventions |
### Tia (Test Impact Analysis)
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
@boostsnippet("Tia Example", "shell")
./vendor/bin/pest --parallel --tia
@endboostsnippet
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
### New Validation Expectations
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
@boostsnippet("Pest 5 Validation Expectations", "php")
expect('nuno@pestphp.com')->toBeEmail();
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
expect('192.168.1.1')->toBeIpAddress();
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
expect('example.com')->toBeHostname();
expect('example.co.uk')->toBeDomain();
expect('Zm9vYmFy')->toBeBase64();
expect('deadbeef')->toBeHexadecimal();
@endboostsnippet
### Time-Balanced Sharding
Distribute tests across CI shards by execution time rather than count:
@boostsnippet("Pest Sharding Example", "shell")
./vendor/bin/pest --update-shards
./vendor/bin/pest --shard=1/4
@endboostsnippet
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
@boostsnippet("Pest Browser Test Example", "php")
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
@endboostsnippet
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
@boostsnippet("Pest Smoke Testing Example", "php")
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
@endboostsnippet
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Architecture Testing
@boostsnippet("Architecture Test Example", "php")
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
@endboostsnippet
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
@@ -7,7 +7,7 @@
<div>
<span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
</div>
<div>
-13
View File
@@ -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 = [];
-8
View File
@@ -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);
-3
View File
@@ -11,9 +11,6 @@ use Throwable;
*/
final class Laravel extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations[] = expect('App\Traits')
-3
View File
@@ -9,9 +9,6 @@ namespace Pest\ArchPresets;
*/
final class Php extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations[] = expect([
-3
View File
@@ -12,9 +12,6 @@ use Pest\Expectation;
*/
final class Relaxed extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->eachUserNamespace(
-3
View File
@@ -9,9 +9,6 @@ namespace Pest\ArchPresets;
*/
final class Security extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations[] = expect([
-3
View File
@@ -12,9 +12,6 @@ use Pest\Expectation;
*/
final class Strict extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->eachUserNamespace(
-5
View File
@@ -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);
-8
View File
@@ -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')) {
+1 -7
View File
@@ -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(
+2 -6
View File
@@ -13,13 +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',
@@ -29,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) {
+6 -8
View 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) {
+1 -7
View File
@@ -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);
-12
View File
@@ -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) === []) {
-2
View File
@@ -14,8 +14,6 @@ trait Expectable
/**
* @template TValue
*
* Creates a new Expectation.
*
* @param TValue $value
* @return Expectation<TValue>
*/
-7
View File
@@ -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);
-12
View File
@@ -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);
-11
View File
@@ -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
-2
View File
@@ -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
+24 -121
View File
@@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestCase\ExceptionExpectation;
use PHPUnit\Framework\TestCase\OutputBuffer;
use ReflectionException;
use ReflectionFunction;
use ReflectionParameter;
@@ -35,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];
@@ -143,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) {
@@ -157,9 +104,6 @@ trait Testable
: $hook;
}
/**
* Adds a new "tearDownAfterClass" to the Test Case.
*/
public function __addAfterAll(?Closure $hook): void
{
if (! $hook instanceof Closure) {
@@ -171,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) {
@@ -201,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();
@@ -221,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);
@@ -237,9 +166,6 @@ trait Testable
parent::tearDownAfterClass();
}
/**
* Gets executed before the Test Case.
*/
protected function setUp(...$arguments): void
{
TestSuite::getInstance()->test = $this;
@@ -278,12 +204,14 @@ trait Testable
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
$replay = ReplayType::fromStatus($status);
if ($replay !== ReplayType::None) {
assert($status !== null);
$this->__replay = $replay;
match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
@@ -317,16 +245,12 @@ trait Testable
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
$this->__ran = true;
}
/**
* Initialize test case properties from TestSuite.
*/
public function __initializeTestCase(): void
{
// Return if the test case has already been initialized
if (isset($this->__test)) {
return;
}
@@ -348,9 +272,6 @@ trait Testable
}
}
/**
* Gets executed after the Test Case.
*/
protected function tearDown(...$arguments): void
{
if ($this->__replay !== ReplayType::None) {
@@ -378,8 +299,6 @@ trait Testable
}
/**
* Executes the Test Case current test.
*
* @throws Throwable
*/
private function __runTest(Closure $closure, ...$args): mixed
@@ -431,10 +350,15 @@ trait Testable
unset($this->{$property});
}
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
if ($hasOutputExpectation) {
if ($outputBuffer->hasExpectation()) {
ob_clean();
Closure::bind(function (): void {
$this->expectedString = null;
$this->expectedRegularExpression = null;
}, $outputBuffer, OutputBuffer::class)();
}
$this->setUp();
@@ -445,12 +369,11 @@ trait Testable
throw $lastException;
}
/**
* Determines if the given exception matches PHPUnit's expected exception.
*/
private function __isExpectedException(Throwable $e): bool
{
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
$expectedClass = $read('expectedException');
@@ -458,13 +381,19 @@ trait Testable
return $e instanceof $expectedClass;
}
$expectedMessage = $read('expectedExceptionMessage');
$expectedMessage = $read('expectedMessage');
if ($expectedMessage !== null) {
return str_contains($e->getMessage(), (string) $expectedMessage);
}
$expectedCode = $read('expectedExceptionCode');
$expectedMessageRegex = $read('expectedMessageRegularExpression');
if ($expectedMessageRegex !== null) {
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
}
$expectedCode = $read('expectedCode');
if ($expectedCode !== null) {
return $e->getCode() === $expectedCode;
@@ -474,8 +403,6 @@ trait Testable
}
/**
* Resolve the passed arguments. Any Closures will be bound to the testcase and resolved.
*
* @throws Throwable
*/
private function __resolveTestArguments(array $arguments): array
@@ -483,10 +410,6 @@ trait Testable
$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;
}
@@ -535,8 +458,6 @@ trait Testable
}
/**
* Ensures dataset items count matches underlying test case required parameters
*
* @throws ReflectionException
* @throws DatasetArgumentsMismatch
*/
@@ -576,9 +497,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;
@@ -594,33 +512,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 [
@@ -631,9 +537,6 @@ trait Testable
];
}
/**
* Opens a shell for the test case.
*/
public function shell(): void
{
Shell::open();
+8 -47
View File
@@ -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);
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,89 +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);
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();
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 TIA (Test Impact Analysis) configuration.
*/
public function evals(): Evals\Configuration // @phpstan-ignore-line
{
return new Evals\Configuration; // @phpstan-ignore-line
}
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/**
* Proxies calls to the uses method.
*
* @param array<array-key, mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
-3
View File
@@ -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);
-3
View File
@@ -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);
+6 -31
View File
@@ -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,22 +41,18 @@ final class Project
return $this;
}
/**
* Sets the test project to GitLab.
*/
public function gitlab(string $project): self
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
{
$this->issues = "https://gitlab.com/{$project}/issues/%s";
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
$this->assignees = 'https://gitlab.com/%s';
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
$this->assignees = "https://{$hostname}/%s";
return $this;
}
/**
* Sets the test project to Bitbucket.
*/
public function bitbucket(string $project): self
{
$this->issues = "https://bitbucket.org/{$project}/issues/%s";
@@ -82,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";
@@ -94,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;
+1 -9
View File
@@ -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) {
+1 -9
View File
@@ -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);
-3
View File
@@ -9,8 +9,5 @@ namespace Pest\Contracts;
*/
interface Bootstrapper
{
/**
* Boots the bootstrapper.
*/
public function boot(): void;
}
+1 -1
View File
@@ -11,5 +11,5 @@ use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName as
*/
interface HasPrintableTestCaseName extends BaseHasPrintableTestCaseName
{
// ..
//
}
-6
View File
@@ -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;
}
-3
View File
@@ -9,8 +9,5 @@ namespace Pest\Contracts\Plugins;
*/
interface AddsOutput
{
/**
* Adds output after the Test Suite execution.
*/
public function addOutput(int $exitCode): int;
}
-3
View File
@@ -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;
-3
View File
@@ -9,8 +9,5 @@ namespace Pest\Contracts\Plugins;
*/
interface Terminable
{
/**
* Terminates the plugin.
*/
public function terminate(): void;
}
-3
View File
@@ -6,8 +6,5 @@ namespace Pest\Contracts;
interface TestCaseFilter
{
/**
* Whether the test case is accepted.
*/
public function accept(string $testCaseFilename): bool;
}
-3
View File
@@ -8,8 +8,5 @@ use Pest\Factories\TestCaseMethodFactory;
interface TestCaseMethodFilter
{
/**
* Whether the test case method is accepted.
*/
public function accept(TestCaseMethodFactory $factory): bool;
}
-2
View File
@@ -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
+1 -4
View File
@@ -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));
}
}
+1 -4
View File
@@ -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));
}
}
+1 -4
View File
@@ -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));
}
}
+1 -4
View File
@@ -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));
}
}
+1 -4
View File
@@ -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));
}
}
+1 -4
View File
@@ -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));
}
}
+2 -4
View File
@@ -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));
}
}
//
}
+1 -4
View File
@@ -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));
}
}
+2 -4
View File
@@ -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,
));
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use RuntimeException;
use Throwable;
/**
* @internal
*/
final class DatasetProviderError extends RuntimeException
{
public function __construct(Throwable $previous)
{
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
}
}
+1 -4
View 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()].");
}
}
+1 -4
View File
@@ -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);
+1 -1
View File
@@ -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)));
}
}
+1 -1
View File
@@ -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));
}
}
-3
View File
@@ -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);
+1 -4
View File
@@ -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.');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
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 file.',
$filename,
$className,
));
}
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, because [%s] may not be used as a namespace name. Please rename that folder.',
$filename,
$namespace,
$part,
));
}
}
+1 -4
View File
@@ -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));
}
}
-6
View File
@@ -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;
+1 -7
View File
@@ -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));
+1 -4
View File
@@ -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));
}
}
+1 -4
View File
@@ -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
)
+1 -4
View File
@@ -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));
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresCommit extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct()
{
parent::__construct(
'Tia mode requires a repository with at least one commit, so the baseline it records can be anchored to a revision.',
);
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires at least one commit.',
'',
' A baseline is anchored to the revision it was recorded at, and this repository',
' has none yet, so there is nothing to record against and nothing to compare a',
' later run to.',
'',
' Commit once, then run again:',
'',
' <fg=yellow>git add . && git commit -m "Initial commit"</>',
'',
' Runs without <fg=yellow>--tia</> are unaffected.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresDefaultBranch extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct()
{
parent::__construct(
'Tia mode could not determine the default branch every other branch falls back to reading.',
);
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode could not determine the default branch.',
'',
' It is the branch whose baseline every other branch falls back to reading, and',
' nothing in this checkout names it: the repository has a remote, but no',
' <fg=yellow>origin/HEAD</>, and no CI provider stated it either. Guessing would re-run the',
' whole suite on every new branch while reporting it as a cache hit.',
'',
' Name the branch in <fg=yellow>tests/Pest.php</>:',
'',
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
'',
' Or let git answer, once per clone:',
'',
' <fg=yellow>git remote set-head origin --auto</>',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
+1 -1
View File
@@ -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,
));
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresRemote extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct()
{
parent::__construct(
'Tia mode requires a repository with a remote, so the default branch every other branch falls back to reading can be resolved.',
);
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires a repository with a remote.',
'',
' Without one there is no way to tell which branch is the default — the branch',
' whose baseline every other branch falls back to reading — so the first run on',
' each new branch would silently re-run the whole suite.',
'',
' Add a remote, or name the branch yourself in <fg=yellow>tests/Pest.php</>:',
'',
' <fg=yellow>pest()->tia()->defaultBranch(\'master\');</>',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $subdirectoryPrefix)
{
parent::__construct(sprintf(
'Tia mode requires the project root to be the git repository root, but this project sits in the subdirectory [%s] of a larger repository. Please give it its own repository to use Tia.',
$this->subdirectoryPrefix,
));
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
'',
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
'',
' Give the project its own git repository to use Tia.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
+22 -221
View File
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest;
use Attribute;
use BadMethodCallException;
use Closure;
use InvalidArgumentException;
@@ -60,19 +59,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 +79,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 +96,6 @@ final class Expectation
}
/**
* Dump the expectation value.
*
* @return self<TValue>
*/
public function dump(mixed ...$arguments): self
@@ -112,31 +103,32 @@ final class Expectation
if (function_exists('dump')) {
dump($this->value, ...$arguments);
} else {
var_dump($this->value);
var_dump($this->value, ...$arguments);
}
return $this;
}
/**
* Dump the expectation value and end the script.
*
* @return never
*/
public function dd(mixed ...$arguments): void
public function dd(mixed ...$arguments): never
{
if (function_exists('dd')) {
dd($this->value, ...$arguments);
}
var_dump($this->value);
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
ob_start();
var_dump($this->value, ...$arguments);
$output = ob_get_clean();
throw new ExpectationFailedException($output);
}
var_dump($this->value, ...$arguments);
exit(1);
}
/**
* Dump the expectation value when the result of the condition is truthy.
*
* @param (Closure(TValue): bool)|bool $condition
* @return self<TValue>
*/
@@ -152,8 +144,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>
*/
@@ -169,8 +159,6 @@ final class Expectation
}
/**
* Send the expectation value to Ray along with all given arguments.
*
* @return self<TValue>
*/
public function ray(mixed ...$arguments): self
@@ -183,8 +171,6 @@ final class Expectation
}
/**
* Creates the opposite expectation for the value.
*
* @return OppositeExpectation<TValue>
*/
public function not(): OppositeExpectation
@@ -193,8 +179,6 @@ final class Expectation
}
/**
* Creates an expectation on each item of the iterable "value".
*
* @return EachExpectation<TValue>
*/
public function each(?callable $callback = null): EachExpectation
@@ -213,8 +197,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
@@ -238,7 +220,7 @@ final class Expectation
if ($callbacks[$index] instanceof Closure) {
$callbacks[$index](new self($value), new self($key));
} else {
(new self($value))->toEqual($callbacks[$index]);
new self($value)->toEqual($callbacks[$index]);
}
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
@@ -252,8 +234,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
@@ -292,8 +272,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>
@@ -308,8 +286,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>
@@ -328,8 +304,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>
*/
@@ -344,7 +318,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)
));
@@ -373,8 +347,6 @@ final class Expectation
}
/**
* Creates a new expectation closure from the given name.
*
* @throws ExpectationNotFound
*/
private function getExpectationClosure(string $name): Closure
@@ -396,8 +368,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
@@ -416,9 +386,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)
@@ -426,17 +393,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
@@ -444,9 +406,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(
@@ -457,9 +416,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(
@@ -470,9 +426,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target have all methods documented.
*/
public function toHaveMethodsDocumented(): ArchExpectation
{
return Targeted::make(
@@ -489,9 +442,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target have all properties documented.
*/
public function toHavePropertiesDocumented(): ArchExpectation
{
return Targeted::make(
@@ -509,9 +459,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target use the "declare(strict_types=1)" declaration.
*/
public function toUseStrictTypes(): ArchExpectation
{
return Targeted::make(
@@ -522,9 +469,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target uses strict equality.
*/
public function toUseStrictEquality(): ArchExpectation
{
return Targeted::make(
@@ -535,9 +479,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target is final.
*/
public function toBeFinal(): ArchExpectation
{
return Targeted::make(
@@ -548,9 +489,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target is readonly.
*/
public function toBeReadonly(): ArchExpectation
{
return Targeted::make(
@@ -561,9 +499,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target is trait.
*/
public function toBeTrait(): ArchExpectation
{
return Targeted::make(
@@ -574,17 +509,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(
@@ -596,8 +525,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
@@ -607,14 +534,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
@@ -622,57 +547,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(
@@ -705,9 +609,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target is enum.
*/
public function toBeEnum(): ArchExpectation
{
return Targeted::make(
@@ -718,17 +619,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(
@@ -739,17 +634,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(
@@ -760,30 +649,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(
@@ -794,17 +674,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
@@ -846,9 +721,6 @@ final class Expectation
);
}
/**
* Asserts that the given expectation target to not implement any interfaces.
*/
public function toImplementNothing(): ArchExpectation
{
return Targeted::make(
@@ -860,8 +732,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
@@ -878,35 +748,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
@@ -915,23 +777,13 @@ final class Expectation
return Targeted::make(
$this,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
"to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* 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
@@ -939,33 +791,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
@@ -974,8 +815,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
@@ -983,17 +822,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(
@@ -1005,8 +838,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
@@ -1029,8 +860,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
@@ -1053,8 +882,6 @@ final class Expectation
}
/**
* Asserts that the given expectation is iterable and contains camelCase keys.
*
* @return self<TValue>
*/
public function toHaveCamelCaseKeys(string $message = ''): self
@@ -1077,8 +904,6 @@ final class Expectation
}
/**
* Asserts that the given expectation is iterable and contains StudlyCase keys.
*
* @return self<TValue>
*/
public function toHaveStudlyCaseKeys(string $message = ''): self
@@ -1100,78 +925,54 @@ 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(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum()
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* 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');
-13
View File
@@ -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
+2 -32
View File
@@ -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,10 +90,8 @@ final class HigherOrderExpectation
}
/**
* Dynamically calls methods on the class with the given arguments.
*
* @param array<int, mixed> $arguments
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
* @return self<TOriginalValue, mixed>
*/
public function __call(string $name, array $arguments): self
{
@@ -125,9 +104,7 @@ final class HigherOrderExpectation
}
/**
* Accesses properties in the value or in the expectation.
*
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
* @return self<TOriginalValue, mixed>
*/
public function __get(string $name): self
{
@@ -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>
*/
+13 -183
View File
@@ -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
@@ -576,23 +485,13 @@ final readonly class OppositeExpectation
return Targeted::make(
$original,
function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
"not to use traits '".implode("', '", $traits)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* 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
@@ -604,23 +503,12 @@ final readonly class OppositeExpectation
return Targeted::make(
$original,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
"not to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to not implement any interfaces.
*/
public function toImplementNothing(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
@@ -634,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 */
@@ -653,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 */
@@ -669,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 */
@@ -702,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
@@ -721,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 */
@@ -745,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 */
@@ -756,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
*/
@@ -784,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
@@ -804,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
@@ -814,35 +667,24 @@ final readonly class OppositeExpectation
$exporter = Exporter::default();
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.',
$toString($this->original->value),
$exporter->shortenedExport($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
));
}
/**
* 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 */
@@ -852,40 +694,28 @@ final readonly class OppositeExpectation
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $object->reflectionClass->isEnum()
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
'not to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* 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');

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