Compare commits

..

167 Commits

Author SHA1 Message Date
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
407 changed files with 11915 additions and 2661 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: on:
push: push:
branches: [4.x] branches: [5.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 9 * * *'
@@ -24,16 +24,16 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
dependency-version: [prefer-lowest, prefer-stable] dependency-version: [prefer-stable]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with: with:
php-version: 8.3 php-version: 8.4
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets extensions: sockets
@@ -44,15 +44,17 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v5 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }} key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: | restore-keys: |
static-php-8.3-${{ matrix.dependency-version }}-composer- static-php-8.4-${{ matrix.dependency-version }}-composer-
static-php-8.3-composer- static-php-8.4-composer-
- name: Install Dependencies - name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check - name: Profanity Check
+8 -9
View File
@@ -2,7 +2,7 @@ name: Tests
on: on:
push: push:
branches: [4.x] branches: [5.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 9 * * *' - cron: '0 9 * * *'
@@ -24,21 +24,18 @@ jobs:
fail-fast: true fail-fast: true
matrix: matrix:
os: [ubuntu-latest, macos-latest] # windows-latest os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['7.4', '8.0'] symfony: ['8.0']
php: ['8.3', '8.4', '8.5'] php: ['8.4', '8.5']
dependency_version: [prefer-stable] dependency_version: [prefer-stable]
exclude:
- php: '8.3'
symfony: '8.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
@@ -51,7 +48,7 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies - name: Cache Composer dependencies
uses: actions/cache@v5 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with: with:
path: ${{ steps.composer-cache.outputs.dir }} path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }} key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
@@ -66,6 +63,8 @@ jobs:
- name: Install PHP dependencies - name: Install PHP dependencies
shell: bash 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 }}" run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests - name: Unit Tests
+1 -1
View File
@@ -1,7 +1,7 @@
.idea/* .idea/*
.idea/codeStyleSettings.xml .idea/codeStyleSettings.xml
.temp/* .temp/*
composer.lock /composer.lock
/vendor/ /vendor/
coverage.xml coverage.xml
.phpunit.result.cache .phpunit.result.cache
+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
```
+11 -24
View File
@@ -1,7 +1,7 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST"> <img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
<p align="center"> <p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (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="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a> <a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a> <a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
@@ -12,9 +12,9 @@
------ ------
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**. > Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP. **Pest** is an elegant testing framework for PHP developers and AI agents.
- Explore our docs at **[pestphp.com »](https://pestphp.com)** - Explore our docs at **[pestphp.com »](https://pestphp.com)**
- Follow the creator Nuno Maduro: - Follow the creator Nuno Maduro:
@@ -29,26 +29,13 @@
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**. We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
### Platinum Sponsors
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[Mailtrap](https://l.rw.rw/pestphp)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
- **[Tighten](https://tighten.com/?ref=nunomaduro)**
- **[Redberry](https://redberry.international/laravel-development/?utm_source=pest&utm_medium=banner&utm_campaign=pest_sponsorship)**
### Gold Sponsors
- **[CMS Max](https://cmsmax.com/?ref=pestphp)** - **[CMS Max](https://cmsmax.com/?ref=pestphp)**
- **[PhpStorm](https://jb.gg/nuno)**
### Premium Sponsors - **[CodeRabbit](https://coderabbit.link/nunomaduro)**
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
- [Zapiet](https://zapiet.com/?ref=pestphp) - **[Typesense](https://typesense.org/?ref=nunomaduro)**
- [Load Forge](https://loadforge.com/?ref=pestphp) - **[Bento](https://bentonow.com/?ref=nunomaduro)**
- [Route4Me](https://route4me.com/pt?ref=pestphp) - **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
- [Nerdify](https://getnerdify.com/?ref=pestphp) - **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
- [Akaunting](https://akaunting.com/?ref=pestphp)
- [TestMu AI](https://www.testmuai.com/?utm_medium=sponsor&utm_source=pest)
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**. Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
+3 -3
View File
@@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done: When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v3 you should use the `3.x` branch instead.** > **For Pest v4 you should use the `4.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x` - Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x) - On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
- Update the version number in [src/Pest.php](src/Pest.php) - Update the version number in [src/Pest.php](src/Pest.php)
- Run the tests locally using: `composer test` - Run the tests locally using: `composer test`
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"` - Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
+154 -19
View File
@@ -25,26 +25,72 @@ const PAGE_DIR_CANDIDATES = [
async function loadRolldown() { async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const path = projectRequire.resolve('rolldown') let path = null
try { path = projectRequire.resolve('rolldown') } catch {}
if (path === null) {
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
// dependency of the vite package rather than a top-level install.
try {
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
path = viteRequire.resolve('rolldown')
} catch {}
}
if (path === null) return null
return await import(pathToFileURL(path).href) return await import(pathToFileURL(path).href)
} }
export function stripJsonComments(raw) {
let out = ''
let inString = false
let quote = ''
let inLine = false
let inBlock = false
for (let i = 0; i < raw.length; i++) {
const c = raw[i]
const n = raw[i + 1]
if (inLine) {
if (c === '\n') { inLine = false; out += c }
continue
}
if (inBlock) {
if (c === '*' && n === '/') { inBlock = false; i++ }
continue
}
if (inString) {
out += c
if (c === '\\') { out += n ?? ''; i++; continue }
if (c === quote) inString = false
continue
}
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
if (c === '/' && n === '/') { inLine = true; i++; continue }
if (c === '/' && n === '*') { inBlock = true; i++; continue }
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
out += c
}
return out
}
async function readJsonWithComments(path) { async function readJsonWithComments(path) {
const raw = await readFile(path, 'utf8') const raw = await readFile(path, 'utf8')
const stripped = raw return JSON.parse(stripJsonComments(raw))
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
return JSON.parse(stripped)
} }
async function loadAliasFromTsconfig() { export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
const alias = {} const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) { for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(PROJECT_ROOT, name) const p = join(projectRoot, name)
if (!existsSync(p)) continue if (!existsSync(p)) continue
let cfg let cfg
try { cfg = await readJsonWithComments(p) } catch { continue } try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.') const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
const paths = cfg?.compilerOptions?.paths ?? {} const paths = cfg?.compilerOptions?.paths ?? {}
for (const [key, targets] of Object.entries(paths)) { for (const [key, targets] of Object.entries(paths)) {
if (!key.endsWith('/*')) continue if (!key.endsWith('/*')) continue
@@ -58,6 +104,74 @@ async function loadAliasFromTsconfig() {
return alias return alias
} }
const VITE_CONFIG_FILES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
'vite.config.cts',
]
function resolveAliasTarget(projectRoot, target) {
if (target.startsWith('/')) {
// Vite resolves a leading slash against the project root, unless the
// config author used a genuinely absolute path.
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
}
return resolve(projectRoot, target)
}
async function usesLaravelVitePlugin(projectRoot) {
const p = join(projectRoot, 'package.json')
if (!existsSync(p)) return false
try {
const pkg = JSON.parse(await readFile(p, 'utf8'))
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
} catch {
return false
}
}
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
const alias = {}
let source = null
for (const name of VITE_CONFIG_FILES) {
const p = join(projectRoot, name)
if (!existsSync(p)) continue
try { source = await readFile(p, 'utf8') } catch { continue }
break
}
if (source !== null) {
// The config is executable code we cannot import safely, so extract alias
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
// the value expression — covers `'@': '/resources/js'` as well as
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
// top-level comma; call arguments are kept via the paren group.
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
const key = m[1]
if (alias[key] !== undefined) continue
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
const target = paths.length > 0 ? paths[paths.length - 1] : null
if (!target || target.includes('*')) continue
alias[key] = resolveAliasTarget(projectRoot, target)
}
}
// laravel-vite-plugin registers '@' → resources/js by default.
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
alias['@'] = resolve(projectRoot, 'resources/js')
}
return alias
}
async function listPageFiles(pagesDir) { async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return [] if (!existsSync(pagesDir)) return []
@@ -121,8 +235,16 @@ async function main() {
return return
} }
const { rolldown } = await loadRolldown() const loaded = await loadRolldown()
const alias = await loadAliasFromTsconfig()
if (loaded === null) {
process.stdout.write('{}')
return
}
const { rolldown } = loaded
// The vite config is what the dev server actually resolves with — let it win.
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
const aliasKeys = Object.keys(alias) const aliasKeys = Object.keys(alias)
const graph = new Map() const graph = new Map()
@@ -167,7 +289,7 @@ async function main() {
cwd: PROJECT_ROOT, cwd: PROJECT_ROOT,
resolve: { resolve: {
alias, alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'], extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
}, },
transform: { jsx: 'preserve' }, transform: { jsx: 'preserve' },
treeshake: false, treeshake: false,
@@ -192,6 +314,11 @@ async function main() {
stack.add(id) stack.add(id)
const acc = new Set() const acc = new Set()
// A set computed while skipping an in-stack (cyclic) dependency is missing
// that dependency's subtree and must not be memoized — the ancestor call
// completes it for the current traversal, but a cached copy would leak the
// incomplete set into other pages' traversals.
let complete = true
const deps = graph.get(id) const deps = graph.get(id)
if (deps) { if (deps) {
for (const dep of deps) { for (const dep of deps) {
@@ -200,13 +327,17 @@ async function main() {
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/') const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
acc.add(rel) acc.add(rel)
} }
if (stack.has(dep)) continue if (stack.has(dep)) {
complete = false
continue
}
const child = computeTransitive(dep, stack) const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r) if (child) for (const r of child) acc.add(r)
if (!transitiveCache.has(dep)) complete = false
} }
} }
stack.delete(id) stack.delete(id)
transitiveCache.set(id, acc) if (complete) transitiveCache.set(id, acc)
return acc return acc
} }
@@ -230,10 +361,14 @@ async function main() {
process.stdout.write(JSON.stringify(payload)) process.stdout.write(JSON.stringify(payload))
} }
try { const invokedDirectly = process.argv[1] !== undefined
void pathToFileURL && import.meta.url === pathToFileURL(process.argv[1]).href
await main()
} catch (err) { if (invokedDirectly) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) try {
process.exit(1) await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}
} }
+19 -16
View File
@@ -17,21 +17,20 @@
} }
], ],
"require": { "require": {
"php": "^8.3.0", "php": "^8.4",
"brianium/paratest": "^7.20.0", "brianium/paratest": "^7.23.1",
"composer/xdebug-handler": "^3.0.5", "nunomaduro/collision": "^8.9.5",
"nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-mutate": "^5.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1", "pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^12.5.24", "phpunit/phpunit": "^13.2.6",
"symfony/process": "^7.4.8|^8.0.8" "symfony/process": "^8.1.0"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.24", "phpunit/phpunit": ">13.2.6",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@@ -49,6 +48,7 @@
"Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers", "Tests\\Fixtures\\Covers\\": "tests/Fixtures/Covers",
"Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance", "Tests\\Fixtures\\Inheritance\\": "tests/Fixtures/Inheritance",
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch", "Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
"Tests\\Fixtures\\Tia\\": "tests/Fixtures/Tia",
"Tests\\": "tests/PHPUnit/" "Tests\\": "tests/PHPUnit/"
}, },
"classmap": [ "classmap": [
@@ -59,17 +59,19 @@
] ]
}, },
"require-dev": { "require-dev": {
"mrpunyapal/peststan": "^0.2.9", "pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-phpstan": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^4.0.4", "pestphp/pest-plugin-rector": "^5.0.2",
"psy/psysh": "^0.12.22" "pestphp/pest-plugin-type-coverage": "^5.0.2",
"psy/psysh": "^0.12.24"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
"config": { "config": {
"sort-packages": true, "sort-packages": true,
"preferred-install": "dist", "preferred-install": "dist",
"process-timeout": 0,
"allow-plugins": { "allow-plugins": {
"pestphp/pest-plugin": true "pestphp/pest-plugin": true
} }
@@ -93,6 +95,7 @@
"test:inline": "php bin/pest --configuration=phpunit.inline.xml", "test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3", "test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v", "test:integration": "php bin/pest --group=integration -v",
"test:tia": "php bin/pest --group=tia -v",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [ "test": [
"@test:lint", "@test:lint",
@@ -0,0 +1,71 @@
<?php
/*
* The MIT License (MIT)
*
* Copyright (c) 2011 Brian Scaturro
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/**
* @internal
*
* This file is overridden so the "T" progress character — emitted by Pest for
* "todo" tests — is routed to the progress file next to the regular characters,
* instead of being treated as unexpected output.
*/
final readonly class ProgressPrinterOutput implements Printer
{
public function __construct(
private Printer $progressPrinter,
private Printer $outputPrinter,
) {}
public function print(string $buffer): void
{
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
if (
$buffer === "\n"
|| preg_match('/^ +$/', $buffer) === 1
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
) {
return;
}
match ($buffer) {
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
default => $this->outputPrinter->print($buffer),
};
}
public function flush(): void
{
$this->progressPrinter->flush();
$this->outputPrinter->flush();
}
}
+2 -2
View File
@@ -90,11 +90,11 @@ final class TestSuiteLoader
(static function () use ($suiteClassFile) { (static function () use ($suiteClassFile) {
try { try {
include_once $suiteClassFile; include_once $suiteClassFile;
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
} catch (Throwable $e) { } catch (Throwable $e) {
Panic::with($e); Panic::with($e);
} }
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
})(); })();
$loadedClasses = array_values( $loadedClasses = array_values(
@@ -43,7 +43,7 @@ declare(strict_types=1);
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace Pest\Logging\TeamCity\Subscriber; namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber; use PHPUnit\Event\Test\SkippedSubscriber;
@@ -51,16 +51,20 @@ use ReflectionClass;
/** /**
* @internal This class is not covered by the backward compatibility promise for PHPUnit * @internal This class is not covered by the backward compatibility promise for PHPUnit
*
* This file is overridden so PHPUnit's progress output emits a "T" before the
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
* "T" and swallows the "S" that follows it.
*/ */
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{ {
public function notify(Skipped $event): void public function notify(Skipped $event): void
{ {
if (str_contains($event->message(), '__TODO__')) { if ($event->message() === '__TODO__') {
$this->printTodoItem(); $this->printTodoItem();
} }
$this->logger()->testSkipped($event); $this->printer()->testSkipped();
} }
/** /**
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
private function printTodoItem(): void private function printTodoItem(): void
{ {
$mirror = new ReflectionClass($this->printer()); $mirror = new ReflectionClass($this->printer());
$printerMirror = $mirror->getMethod('printProgress'); $printProgress = $mirror->getMethod('printProgress');
$printerMirror->invoke($this->printer(), 'T'); $printProgress->invoke($this->printer(), 'T');
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
includes: includes:
- phpstan-baseline.neon - phpstan-baseline.neon
- phpstan-pest-extension.neon - phpstan-pest-extension.neon
- vendor/mrpunyapal/peststan/extension.neon - vendor/pestphp/pest-plugin-phpstan/extension.neon
parameters: parameters:
level: 7 level: 7
+2 -1
View File
@@ -18,8 +18,9 @@
<directory suffix=".php">./tests</directory> <directory suffix=".php">./tests</directory>
<directory suffix=".php">./tests-external</directory> <directory suffix=".php">./tests-external</directory>
<exclude>./tests/.snapshots</exclude> <exclude>./tests/.snapshots</exclude>
<exclude>./tests/.tests</exclude>
<exclude>./tests/Fixtures/Inheritance</exclude> <exclude>./tests/Fixtures/Inheritance</exclude>
<exclude>./tests/Fixtures/Suites</exclude>
<exclude>./tests/Fixtures/Tia</exclude>
</testsuite> </testsuite>
</testsuites> </testsuites>
<source> <source>
+5
View File
@@ -0,0 +1,5 @@
{
"exclude": [
"tests/Fixtures/Suites"
]
}
+39
View File
@@ -2,22 +2,61 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Rector\Rules\UseToMatchArrayRector;
use Pest\Rector\Set\PestSetList;
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector; use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig; use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector; use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
return RectorConfig::configure() return RectorConfig::configure()
->withPaths([ ->withPaths([
__DIR__.'/src', __DIR__.'/src',
__DIR__.'/tests',
])
->withSets([
PestSetList::CODING_STYLE,
]) ])
->withSkip([ ->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php', __DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
__DIR__.'/tests/Fixtures/Arch',
__DIR__.'/tests/Fixtures/Suites',
ReturnNeverTypeRector::class, ReturnNeverTypeRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class, ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class, NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class, RemoveParentDelegatingConstructorRector::class,
RemoveDuplicatedReturnSelfDocblockRector::class,
RemoveUselessUnionReturnDocblockRector::class,
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
],
// Merges unrelated expectations into a single `toMatchArray()`, turning
// `toContain()` into exact matches, dropping `->not`, and mistaking a
// `toBeTrue()` failure message for an expected value. Unsafe here.
UseToMatchArrayRector::class,
// Test fixtures rely on "unused" constructors, params and properties
// (resolved via the container or read through reflection), so the
// dead-code and return-type rules below must not touch the test suite.
RemoveEmptyClassMethodRector::class => [
__DIR__.'/tests',
],
RemoveUnusedConstructorParamRector::class => [
__DIR__.'/tests',
],
RemoveUnusedPrivatePropertyRector::class => [
__DIR__.'/tests',
],
AddArrowFunctionReturnTypeRector::class => [
__DIR__.'/tests',
],
]) ])
->withPreparedSets( ->withPreparedSets(
deadCode: true, deadCode: true,
@@ -0,0 +1,196 @@
---
name: pest-testing
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
license: MIT
metadata:
author: laravel
---
@php
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
@endphp
# Pest Testing 5
## Documentation
Use `search-docs` for detailed Pest 5 patterns and documentation.
## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
The `{name}` argument should include only the path and test name, but should not include the test suite.
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
### Test Organization
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
- Browser tests: `tests/Browser/` directory.
- Do NOT remove tests without approval - these are core application code.
### Basic Test Structure
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
@boostsnippet("Basic Pest Test Example", "php")
it('is true', function () {
expect(true)->toBeTrue();
});
@endboostsnippet
### Running Tests
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
## Assertions
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
@boostsnippet("Pest Response Assertion", "php")
it('returns all', function () {
$this->postJson('/api/docs', [])->assertSuccessful();
});
@endboostsnippet
| Use | Instead of |
|-----|------------|
| `assertSuccessful()` | `assertStatus(200)` |
| `assertNotFound()` | `assertStatus(404)` |
| `assertForbidden()` | `assertStatus(403)` |
## Mocking
Import mock function before use: `use function Pest\Laravel\mock;`
## Datasets
Use datasets for repetitive tests (validation rules, etc.):
@boostsnippet("Pest Dataset Example", "php")
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
@endboostsnippet
## Pest 5 Features
| Feature | Purpose |
|---------|---------|
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
| Time-Balanced Sharding | Split tests across CI shards by execution time |
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
| Browser Testing | Full integration tests in real browsers |
| Smoke Testing | Validate multiple pages quickly |
| Visual Regression | Compare screenshots for visual changes |
| Architecture Testing | Enforce code conventions |
### Tia (Test Impact Analysis)
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
@boostsnippet("Tia Example", "shell")
./vendor/bin/pest --parallel --tia
@endboostsnippet
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
### New Validation Expectations
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
@boostsnippet("Pest 5 Validation Expectations", "php")
expect('nuno@pestphp.com')->toBeEmail();
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
expect('192.168.1.1')->toBeIpAddress();
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
expect('example.com')->toBeHostname();
expect('example.co.uk')->toBeDomain();
expect('Zm9vYmFy')->toBeBase64();
expect('deadbeef')->toBeHexadecimal();
@endboostsnippet
### Time-Balanced Sharding
Distribute tests across CI shards by execution time rather than count:
@boostsnippet("Pest Sharding Example", "shell")
./vendor/bin/pest --update-shards
./vendor/bin/pest --shard=1/4
@endboostsnippet
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
### Browser Test Example
Browser tests run in real browsers for full integration testing:
- Browser tests live in `tests/Browser/`.
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
- Use `RefreshDatabase` for clean state per test.
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging.
@boostsnippet("Pest Browser Test Example", "php")
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in');
$page->assertSee('Sign In')
->assertNoJavaScriptErrors()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!');
Notification::assertSent(ResetPassword::class);
});
@endboostsnippet
### Smoke Testing
Quickly validate multiple pages have no JavaScript errors:
@boostsnippet("Pest Smoke Testing Example", "php")
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
@endboostsnippet
### Visual Regression Testing
Capture and compare screenshots to detect visual changes.
### Architecture Testing
@boostsnippet("Architecture Test Example", "php")
arch('controllers')
->expect('App\Http\Controllers')
->toExtendNothing()
->toHaveSuffix('Controller');
@endboostsnippet
## Common Pitfalls
- Not importing `use function Pest\Laravel\mock;` before using mock
- Using `assertStatus(200)` instead of `assertSuccessful()`
- Forgetting datasets for repetitive validation tests
- Deleting tests without approval
- Forgetting `assertNoJavaScriptErrors()` in browser tests
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
@@ -7,7 +7,7 @@
<div> <div>
<span class="text-gray mr-1">- </span> <span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span> <span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
</div> </div>
<div> <div>
+1
View File
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
* @var array<int, string> * @var array<int, string>
*/ */
public const array FILES = [ public const array FILES = [
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
'Runner/Filter/NameFilterIterator.php', 'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php', 'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php', 'Runner/TestSuiteLoader.php',
+6
View File
@@ -35,6 +35,12 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class, Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class, Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::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, Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
]; ];
+24 -7
View File
@@ -23,6 +23,8 @@ use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest; use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest; use PHPUnit\Framework\SkippedTest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestCase\ExceptionExpectation;
use PHPUnit\Framework\TestCase\OutputBuffer;
use ReflectionException; use ReflectionException;
use ReflectionFunction; use ReflectionFunction;
use ReflectionParameter; use ReflectionParameter;
@@ -278,12 +280,14 @@ trait Testable
/** @var Tia $tia */ /** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class); $tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name()); $status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
$replay = ReplayType::fromStatus($status); $replay = ReplayType::fromStatus($status);
if ($replay !== ReplayType::None) { if ($replay !== ReplayType::None) {
assert($status !== null); assert($status !== null);
$this->__replay = $replay;
match ($replay) { match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia), ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()), ReplayType::Skipped => $this->markTestSkipped($status->message()),
@@ -317,7 +321,7 @@ trait Testable
private function __beginReplay(ReplayType $replay, Tia $tia): void private function __beginReplay(ReplayType $replay, Tia $tia): void
{ {
$this->__replay = $replay; $this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name()); $this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
$this->__ran = true; $this->__ran = true;
} }
@@ -431,10 +435,15 @@ trait Testable
unset($this->{$property}); unset($this->{$property});
} }
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)(); $outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
if ($hasOutputExpectation) { if ($outputBuffer->hasExpectation()) {
ob_clean(); ob_clean();
Closure::bind(function (): void {
$this->expectedString = null;
$this->expectedRegularExpression = null;
}, $outputBuffer, OutputBuffer::class)();
} }
$this->setUp(); $this->setUp();
@@ -450,7 +459,9 @@ trait Testable
*/ */
private function __isExpectedException(Throwable $e): bool private function __isExpectedException(Throwable $e): bool
{ {
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)(); $expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
$expectedClass = $read('expectedException'); $expectedClass = $read('expectedException');
@@ -458,13 +469,19 @@ trait Testable
return $e instanceof $expectedClass; return $e instanceof $expectedClass;
} }
$expectedMessage = $read('expectedExceptionMessage'); $expectedMessage = $read('expectedMessage');
if ($expectedMessage !== null) { if ($expectedMessage !== null) {
return str_contains($e->getMessage(), (string) $expectedMessage); return str_contains($e->getMessage(), (string) $expectedMessage);
} }
$expectedCode = $read('expectedExceptionCode'); $expectedMessageRegex = $read('expectedMessageRegularExpression');
if ($expectedMessageRegex !== null) {
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
}
$expectedCode = $read('expectedCode');
if ($expectedCode !== null) { if ($expectedCode !== null) {
return $e->getCode() === $expectedCode; return $e->getCode() === $expectedCode;
+11 -3
View File
@@ -33,7 +33,7 @@ final readonly class Configuration
*/ */
public function in(string ...$targets): UsesCall public function in(string ...$targets): UsesCall
{ {
return (new UsesCall($this->filename, []))->in(...$targets); return new UsesCall($this->filename, [])->in(...$targets);
} }
/** /**
@@ -60,7 +60,7 @@ final readonly class Configuration
*/ */
public function group(string ...$groups): UsesCall public function group(string ...$groups): UsesCall
{ {
return (new UsesCall($this->filename, []))->group(...$groups); return new UsesCall($this->filename, [])->group(...$groups);
} }
/** /**
@@ -68,7 +68,7 @@ final readonly class Configuration
*/ */
public function only(): void public function only(): void
{ {
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only(); new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
} }
/** /**
@@ -119,6 +119,14 @@ final readonly class Configuration
return new Browser\Configuration; return new Browser\Configuration;
} }
/**
* Gets the evals configuration.
*/
public function evals(): Evals\Configuration // @phpstan-ignore-line
{
return new Evals\Configuration; // @phpstan-ignore-line
}
/** /**
* Gets the TIA (Test Impact Analysis) configuration. * Gets the TIA (Test Impact Analysis) configuration.
*/ */
+7 -4
View File
@@ -59,12 +59,15 @@ final class Project
/** /**
* Sets the test project to GitLab. * 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"; // Simple way to ensure only the host is used
$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; return $this;
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
*/ */
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename)); parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
*/ */
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename)); parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
*/ */
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.'); parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
*/ */
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename)); parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
*/ */
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename)); parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
*/ */
public function __construct(string $name, string $scope) public function __construct(string $name, string $scope)
{ {
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope)); parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
*/ */
public function __construct(string $name) public function __construct(string $name)
{ {
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name)); parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
} }
} }
+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 -1
View File
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
*/ */
public function __construct(string $filename) public function __construct(string $filename)
{ {
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename)); parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
} }
} }
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance for the given class name.
*/
public static function fromClassName(string $filename, string $className): self
{
return new self(sprintf(
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
$filename,
$className,
));
}
/**
* Creates a new Exception instance for the given namespace.
*/
public static function fromNamespace(string $filename, string $namespace, string $part): self
{
return new self(sprintf(
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
$filename,
$namespace,
$part,
));
}
}
+1 -1
View File
@@ -19,6 +19,6 @@ final class MissingDependency extends InvalidArgumentException implements Except
*/ */
public function __construct(string $feature, string $dependency) public function __construct(string $feature, string $dependency)
{ {
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency)); parent::__construct(sprintf('The feature [%s] requires [%s].', $feature, $dependency));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
*/ */
public function __construct(string $fileName, string $description) public function __construct(string $fileName, string $description)
{ {
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName)); parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
} }
} }
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
*/ */
public function __construct(string $testCaseClass) public function __construct(string $testCaseClass)
{ {
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass)); parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
} }
} }
+1 -1
View File
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
*/ */
public function __construct(string $fileName) public function __construct(string $fileName)
{ {
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName)); parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
} }
} }
+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;
}
}
+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 it sits in the subdirectory [%s] of a larger repo.',
$this->subdirectoryPrefix,
));
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
'',
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
'',
' Give the project its own git repository to use Tia.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}
+15 -17
View File
@@ -112,7 +112,7 @@ final class Expectation
if (function_exists('dump')) { if (function_exists('dump')) {
dump($this->value, ...$arguments); dump($this->value, ...$arguments);
} else { } else {
var_dump($this->value); var_dump($this->value, ...$arguments);
} }
return $this; return $this;
@@ -120,16 +120,22 @@ final class Expectation
/** /**
* Dump the expectation value and end the script. * 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')) { if (function_exists('dd')) {
dd($this->value, ...$arguments); 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); exit(1);
} }
@@ -238,7 +244,7 @@ final class Expectation
if ($callbacks[$index] instanceof Closure) { if ($callbacks[$index] instanceof Closure) {
$callbacks[$index](new self($value), new self($key)); $callbacks[$index](new self($value), new self($key));
} else { } else {
(new self($value))->toEqual($callbacks[$index]); new self($value)->toEqual($callbacks[$index]);
} }
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0; $index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
@@ -915,15 +921,7 @@ final class Expectation
return Targeted::make( return Targeted::make(
$this, $this,
function (ObjectDescription $object) use ($interfaces): bool { fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
foreach ($interfaces as $interface) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"to implement '".implode("', '", $interfaces)."'", "to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@@ -1138,8 +1136,8 @@ final class Expectation
$this, $this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum() && $object->reflectionClass->isEnum()
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line && new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line && (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum', 'to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
+2 -2
View File
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
* Dynamically calls methods on the class with the given arguments. * Dynamically calls methods on the class with the given arguments.
* *
* @param array<int, mixed> $arguments * @param array<int, mixed> $arguments
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue> * @return self<TOriginalValue, mixed>
*/ */
public function __call(string $name, array $arguments): self public function __call(string $name, array $arguments): self
{ {
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
/** /**
* Accesses properties in the value or in the expectation. * Accesses properties in the value or in the expectation.
* *
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue> * @return self<TOriginalValue, mixed>
*/ */
public function __get(string $name): self public function __get(string $name): self
{ {
+6 -24
View File
@@ -576,15 +576,7 @@ final readonly class OppositeExpectation
return Targeted::make( return Targeted::make(
$original, $original,
function (ObjectDescription $object) use ($traits): bool { fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
foreach ($traits as $trait) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
"not to use traits '".implode("', '", $traits)."'", "not to use traits '".implode("', '", $traits)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@@ -604,15 +596,7 @@ final readonly class OppositeExpectation
return Targeted::make( return Targeted::make(
$original, $original,
function (ObjectDescription $object) use ($interfaces): bool { fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
foreach ($interfaces as $interface) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"not to implement '".implode("', '", $interfaces)."'", "not to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@@ -814,13 +798,11 @@ final readonly class OppositeExpectation
$exporter = Exporter::default(); $exporter = Exporter::default();
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
throw new ExpectationFailedException(sprintf( throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.', 'Expecting %s not %s %s.',
$toString($this->original->value), $exporter->shortenedExport($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)), strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)), implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
)); ));
} }
@@ -852,8 +834,8 @@ final readonly class OppositeExpectation
$original, $original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $object->reflectionClass->isEnum() || ! $object->reflectionClass->isEnum()
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line || ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line || (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
'not to be '.$backingType.' backed enum', 'not to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
+18 -3
View File
@@ -9,6 +9,7 @@ use Pest\Concerns;
use Pest\Contracts\HasPrintableTestCaseName; use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Evaluators\Attributes; use Pest\Evaluators\Attributes;
use Pest\Exceptions\DatasetMissing; use Pest\Exceptions\DatasetMissing;
use Pest\Exceptions\InvalidTestClassName;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Exceptions\TestAlreadyExist; use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestClosureMustNotBeStatic; use Pest\Exceptions\TestClosureMustNotBeStatic;
@@ -94,7 +95,8 @@ final class TestCaseFactory
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename); $filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
} }
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename))); $realpath = (string) realpath($filename);
$filename = str_replace('\\\\', '\\', addslashes($realpath));
$rootPath = TestSuite::getInstance()->rootPath; $rootPath = TestSuite::getInstance()->rootPath;
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename); $relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
@@ -137,6 +139,16 @@ final class TestCaseFactory
if (trim($className) === '') { if (trim($className) === '') {
$className = 'InvalidTestName'.Str::random(); $className = 'InvalidTestName'.Str::random();
} elseif (! Str::isValidClassName($className)) {
throw InvalidTestClassName::fromClassName($this->filename, $className);
}
if ($this->namespace === null) {
foreach ($partsFQN as $partFQN) {
if (! Str::isValidIdentifier($partFQN)) {
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
}
}
} }
$this->attributes = [ $this->attributes = [
@@ -149,6 +161,8 @@ final class TestCaseFactory
$attributesCode = Attributes::code($this->attributes); $attributesCode = Attributes::code($this->attributes);
$filenameLiteral = var_export($realpath, true);
$methodsCode = implode('', array_map( $methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(), fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
$methods $methods
@@ -158,6 +172,7 @@ final class TestCaseFactory
$classCode = <<<PHP $classCode = <<<PHP
namespace $namespace; namespace $namespace;
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite; use Pest\TestSuite as __PestTestSuite;
@@ -166,7 +181,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode $traitsCode
public static \$__filename = '$filename'; public static \$__filename = $filenameLiteral;
$methodsCode $methodsCode
} }
@@ -197,7 +212,7 @@ final class TestCaseFactory
if ( if (
$method->closure instanceof \Closure && $method->closure instanceof \Closure &&
(new \ReflectionFunction($method->closure))->isStatic() new \ReflectionFunction($method->closure)->isStatic()
) { ) {
throw new TestClosureMustNotBeStatic($method); throw new TestClosureMustNotBeStatic($method);
+9 -1
View File
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
$attributesCode $attributesCode
public function $methodName(...\$arguments) public function $methodName(...\$arguments)
{ {
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
}
return \$this->__runTest( return \$this->__runTest(
\$this->__test, \$this->__test,
...\$arguments, ...\$arguments,
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
public static function $dataProviderName() public static function $dataProviderName()
{ {
return __PestDatasets::get(self::\$__filename, "$methodName"); try {
return __PestDatasets::get(self::\$__filename, "$methodName");
} catch (\Throwable \$throwable) {
return [[new __PestDatasetProviderError(\$throwable)]];
}
} }
EOF; EOF;
+1 -1
View File
@@ -163,7 +163,7 @@ final class Kernel
$this->terminate(); $this->terminate();
if (is_array($error = error_get_last())) { if (is_array($error = error_get_last())) {
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) { if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
return; return;
} }
+10 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest; namespace Pest;
use Laravel\Pao\Execution;
use Pest\Support\View; use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -28,6 +29,10 @@ final class KernelDump
*/ */
public function enable(): void public function enable(): void
{ {
if (class_exists(Execution::class) && Execution::running()) {
return;
}
ob_start(function (string $message): string { ob_start(function (string $message): string {
$this->buffer .= $message; $this->buffer .= $message;
@@ -68,6 +73,10 @@ final class KernelDump
$type = 'INFO'; $type = 'INFO';
if (is_array($error = error_get_last()) && in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
return;
}
if ($this->isInternalError($this->buffer)) { if ($this->isInternalError($this->buffer)) {
$type = 'ERROR'; $type = 'ERROR';
$this->buffer = str_replace( $this->buffer = str_replace(
@@ -107,7 +116,6 @@ final class KernelDump
*/ */
private function isInternalError(string $output): bool private function isInternalError(string $output): bool
{ {
return str_contains($output, 'An error occurred inside PHPUnit.') return str_contains($output, 'An error occurred inside PHPUnit.');
|| str_contains($output, 'Fatal error');
} }
} }
+21 -22
View File
@@ -15,11 +15,6 @@ use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed; use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed; use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite; use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider; use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
use PHPUnit\Framework\Exception as FrameworkException; use PHPUnit\Framework\Exception as FrameworkException;
@@ -254,25 +249,29 @@ final readonly class Converter
...$result->testMarkedIncompleteEvents(), ...$result->testMarkedIncompleteEvents(),
]; ];
$numberOfNotPassedTests = count( $notPassedTests = [];
array_unique(
array_map(
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName();
}
return $this->getTestCaseLocation($event->test()); foreach ($events as $event) {
}, if ($event instanceof AfterLastTestMethodErrored) {
$events // PHPUnit's collector does not count these towards `numberOfTestsRun`...
) continue;
) }
); if ($event instanceof AfterLastTestMethodFailed) {
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
continue;
}
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
$notPassedTests[] = $event->testClassName();
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests; continue;
}
$notPassedTests[] = $this->getTestCaseLocation($event->test());
}
$numberOfPassedTests = $result->numberOfTestsRun()
- count(array_unique($notPassedTests))
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result); return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
} }
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
{ {
public function notify(Skipped $event): void public function notify(Skipped $event): void
{ {
if ($event->message() === '__TODO__') {
return; // "todo" tests are reported in the summary, not as ignored tests...
}
$this->logger()->testSkipped($event); $this->logger()->testSkipped($event);
} }
} }
+6
View File
@@ -241,6 +241,12 @@ final class TeamCityLogger
$telemetry->memoryUsageSinceStart(), $telemetry->memoryUsageSinceStart(),
$telemetry->durationSincePrevious(), $telemetry->durationSincePrevious(),
$telemetry->memoryUsageSincePrevious(), $telemetry->memoryUsageSincePrevious(),
$telemetry->userCpuTimeSinceStart(),
$telemetry->systemCpuTimeSinceStart(),
$telemetry->totalCpuTimeSinceStart(),
$telemetry->userCpuTimeSincePrevious(),
$telemetry->systemCpuTimeSincePrevious(),
$telemetry->totalCpuTimeSincePrevious(),
); );
} }
+134 -1
View File
@@ -936,7 +936,7 @@ final class Expectation
if ($exception instanceof Closure) { if ($exception instanceof Closure) {
$callback = $exception; $callback = $exception;
$parameters = (new ReflectionFunction($exception))->getParameters(); $parameters = new ReflectionFunction($exception)->getParameters();
if (count($parameters) !== 1) { if (count($parameters) !== 1) {
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.'); throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
@@ -954,6 +954,7 @@ final class Expectation
} catch (Throwable $e) { } catch (Throwable $e) {
if ($exception instanceof Throwable) { if ($exception instanceof Throwable) {
// @phpstan-ignore-next-line
expect($e) expect($e)
->toBeInstanceOf($exception::class, $message) ->toBeInstanceOf($exception::class, $message)
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message); ->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
@@ -1141,6 +1142,22 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value is a ULID.
*
* @return self<TValue>
*/
public function toBeUlid(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue(Str::isUlid($this->value), $message);
return $this;
}
/** /**
* Asserts that the value is between 2 specified values * Asserts that the value is between 2 specified values
* *
@@ -1154,6 +1171,22 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value is an email address.
*
* @return self<TValue>
*/
public function toBeEmail(string $message = ''): self
{
if ($message === '') {
$message = "Failed asserting that {$this->value} is an email address.";
}
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
return $this;
}
/** /**
* Asserts that the value is a url * Asserts that the value is a url
* *
@@ -1186,4 +1219,104 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value is an IP address.
*
* @return self<TValue>
*/
public function toBeIpAddress(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
return $this;
}
/**
* Asserts that the value is a MAC address.
*
* @return self<TValue>
*/
public function toBeMacAddress(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
return $this;
}
/**
* Asserts that the value is a hostname.
*
* @return self<TValue>
*/
public function toBeHostname(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
return $this;
}
/**
* Asserts that the value is a domain name.
*
* @return self<TValue>
*/
public function toBeDomain(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
&& str_contains($this->value, '.');
Assert::assertTrue($isValid, $message);
return $this;
}
/**
* Asserts that the value is a base64-encoded string.
*
* @return self<TValue>
*/
public function toBeBase64(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$decoded = base64_decode($this->value, true);
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
return $this;
}
/**
* Asserts that the value is a hexadecimal string.
*
* @return self<TValue>
*/
public function toBeHexadecimal(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue(ctype_xdigit($this->value), $message);
return $this;
}
} }
@@ -19,7 +19,7 @@ use PHPStan\Type\Type;
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as * $expectation, $opposite, $shouldReset) from being incorrectly resolved as
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions. * higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
* *
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension. * This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
* *
* @internal * @internal
*/ */
@@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
$varType = $scope->getType($expr->var); $varType = $scope->getType($expr->var);
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) { if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
return null; return null;
} }
+1 -3
View File
@@ -53,9 +53,7 @@ final class UsesCall
$this->targets = [$filename]; $this->targets = [$filename];
} }
/** #[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
* @deprecated Use `pest()->printer()->compact()` instead.
*/
public function compact(): self public function compact(): self
{ {
DefaultPrinter::compact(true); DefaultPrinter::compact(true);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Pest;
function version(): string function version(): string
{ {
return '4.7.0'; return '5.0.4';
} }
function testDirectory(string $file = ''): string function testDirectory(string $file = ''): string
+1 -1
View File
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
} }
} }
if (! $this->hasArgument('--parallel', $arguments)) { if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
return $this->pushArgument('--cache-result', $arguments); return $this->pushArgument('--cache-result', $arguments);
} }
+6 -3
View File
@@ -50,11 +50,14 @@ trait HandleArguments
*/ */
public function popArgument(string $argument, array $arguments): array public function popArgument(string $argument, array $arguments): array
{ {
$arguments = array_flip($arguments); $key = array_search($argument, $arguments, true);
unset($arguments[$argument]); while ($key !== false) {
unset($arguments[$key]);
$key = array_search($argument, $arguments, true);
}
return array_values(array_flip($arguments)); return array_values($arguments);
} }
/** /**
+3 -3
View File
@@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class Coverage implements AddsOutput, HandlesArguments final class Coverage implements AddsOutput, HandlesArguments
{ {
use Concerns\HandleArguments;
private const string COVERAGE_OPTION = 'coverage'; private const string COVERAGE_OPTION = 'coverage';
private const string MIN_OPTION = 'min'; private const string MIN_OPTION = 'min';
@@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
return false; return false;
}))]; }))];
$originals = array_flip($originals);
foreach ($arguments as $argument) { foreach ($arguments as $argument) {
unset($originals[$argument]); $originals = $this->popArgument($argument, $originals);
} }
$originals = array_flip($originals);
$inputs = []; $inputs = [];
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE); $inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
+35
View File
@@ -114,6 +114,41 @@ final readonly class Help implements HandlesArguments
], ],
]; ];
$content['Tia'] = [
[
'arg' => '--tia',
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
],
[
'arg' => '--no-tia',
'desc' => 'Disable test impact analysis for this run',
],
[
'arg' => '--tia --fresh',
'desc' => 'Discard the recorded dependency graph and record it again',
],
[
'arg' => '--tia --filtered',
'desc' => 'Narrow the run to the affected test files only',
],
[
'arg' => '--tia --locally',
'desc' => 'Enable test impact analysis on local machines only',
],
[
'arg' => '--tia --baselined',
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
],
[
'arg' => '--tia --refetch',
'desc' => 'Force a fresh fetch of the shared dependency graph',
],
[
'arg' => '--baseline',
'desc' => 'Output to standard output the test impact analysis storage directory',
],
];
$content['Execution'] = [...[ $content['Execution'] = [...[
[ [
'arg' => '--parallel', 'arg' => '--parallel',
+1 -7
View File
@@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
{ {
$arguments = new ArgvInput; $arguments = new ArgvInput;
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) { return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
if ($arguments->hasParameterOption($unsupportedArgument)) {
return true;
}
}
return false;
} }
/** /**
@@ -120,10 +120,6 @@ final class ResultPrinter
$unexpectedOutput = $this->tail($outputFile); $unexpectedOutput = $this->tail($outputFile);
if ($unexpectedOutput !== '') { if ($unexpectedOutput !== '') {
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
return;
}
$this->output->write($unexpectedOutput); $this->output->write($unexpectedOutput);
} }
+137 -11
View File
@@ -7,13 +7,14 @@ namespace Pest\Plugins\Parallel\Paratest;
use const DIRECTORY_SEPARATOR; use const DIRECTORY_SEPARATOR;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection; use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger; use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer; use ParaTest\JUnit\Writer;
use ParaTest\Options; use ParaTest\Options;
use ParaTest\RunnerInterface; use ParaTest\RunnerInterface;
use ParaTest\WrapperRunner\MissingResultsException;
use ParaTest\WrapperRunner\SuiteLoader; use ParaTest\WrapperRunner\SuiteLoader;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Plugins\Tia;
use Pest\Result; use Pest\Result;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Event\Facade as EventFacade;
@@ -25,11 +26,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\Util\ExcludeList; use PHPUnit\Util\ExcludeList;
use ReflectionProperty;
use SebastianBergmann\CodeCoverage\Node\Builder;
use SebastianBergmann\CodeCoverage\Serialization\Merger;
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
use SebastianBergmann\Timer\Timer; use SebastianBergmann\Timer\Timer;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\PhpExecutableFinder;
use function array_filter;
use function array_merge; use function array_merge;
use function array_merge_recursive; use function array_merge_recursive;
use function array_shift; use function array_shift;
@@ -37,6 +44,8 @@ use function assert;
use function count; use function count;
use function dirname; use function dirname;
use function file_get_contents; use function file_get_contents;
use function filesize;
use function is_file;
use function max; use function max;
use function realpath; use function realpath;
use function str_starts_with; use function str_starts_with;
@@ -49,14 +58,14 @@ use function usleep;
*/ */
final class WrapperRunner implements RunnerInterface final class WrapperRunner implements RunnerInterface
{ {
/**
* The time to sleep between cycles.
*/
/** /**
* The merged test result from the parallel run. * The merged test result from the parallel run.
*/ */
public static ?TestResult $result = null; public static ?TestResult $result = null;
/**
* The time to sleep between cycles.
*/
private const int CYCLE_SLEEP = 10000; private const int CYCLE_SLEEP = 10000;
/** /**
@@ -83,6 +92,18 @@ final class WrapperRunner implements RunnerInterface
/** @var array<int,int> */ /** @var array<int,int> */
private array $batches = []; private array $batches = [];
/** @var array<non-empty-string,true> */
private array $requiredTestResultFiles = [];
/** @var array<non-empty-string,true> */
private array $requiredCoverageFiles = [];
/** @var list<SplFileInfo> */
private array $statusFiles = [];
/** @var list<SplFileInfo> */
private array $progressFiles = [];
/** @var list<SplFileInfo> */ /** @var list<SplFileInfo> */
private array $unexpectedOutputFiles = []; private array $unexpectedOutputFiles = [];
@@ -135,6 +156,7 @@ final class WrapperRunner implements RunnerInterface
/** @var array<int, non-empty-string> $parameters */ /** @var array<int, non-empty-string> $parameters */
$parameters = $this->handleLaravelHerd($parameters); $parameters = $this->handleLaravelHerd($parameters);
$parameters = $this->handleTia($parameters);
$parameters[] = $wrapper; $parameters[] = $wrapper;
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath; $parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
@@ -146,7 +168,6 @@ final class WrapperRunner implements RunnerInterface
public function run(): int public function run(): int
{ {
$directory = dirname(__DIR__); $directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory); ExcludeList::addDirectory($directory);
TestResultFacade::init(); TestResultFacade::init();
EventFacade::instance()->seal(); EventFacade::instance()->seal();
@@ -183,6 +204,19 @@ final class WrapperRunner implements RunnerInterface
return $parameters; return $parameters;
} }
/**
* @param array<int, non-empty-string> $parameters
* @return array<int, non-empty-string>
*/
private function handleTia(array $parameters): array
{
if (! Tia::recordsEdgesInWorkers()) {
return $parameters;
}
return array_merge($parameters, ['-d', 'pcov.directory='.TestSuite::getInstance()->rootPath]);
}
private function startWorkers(): void private function startWorkers(): void
{ {
for ($token = 1; $token <= $this->options->processes; $token++) { for ($token = 1; $token <= $this->options->processes; $token++) {
@@ -213,7 +247,7 @@ final class WrapperRunner implements RunnerInterface
if ( if (
$this->exitcode > 0 $this->exitcode > 0
&& $this->options->configuration->stopOnFailure() && $this->options->configuration->stopOnFailureThreshold() > 0
) { ) {
$this->pending = []; $this->pending = [];
} elseif (($pending = array_shift($this->pending)) !== null) { } elseif (($pending = array_shift($this->pending)) !== null) {
@@ -228,6 +262,18 @@ final class WrapperRunner implements RunnerInterface
private function flushWorker(WrapperWorker $worker): void private function flushWorker(WrapperWorker $worker): void
{ {
if ($worker->hasExecutedTests()) {
$testResultFile = $worker->testResultFile->getPathname();
if ($testResultFile !== '') {
$this->requiredTestResultFiles[$testResultFile] = true;
}
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
}
}
$this->exitcode = max($this->exitcode, $worker->getExitCode()); $this->exitcode = max($this->exitcode, $worker->getExitCode());
$this->printer->printFeedback( $this->printer->printFeedback(
$worker->progressFile, $worker->progressFile,
@@ -275,10 +321,15 @@ final class WrapperRunner implements RunnerInterface
$worker->start(); $worker->start();
$this->batches[$token] = 0; $this->batches[$token] = 0;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile; $this->statusFiles[] = $worker->statusFile;
$this->progressFiles[] = $worker->progressFile;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile; $this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testResultFiles[] = $worker->testResultFile; $this->testResultFiles[] = $worker->testResultFile;
if (isset($worker->resultCacheFile)) {
$this->resultCacheFiles[] = $worker->resultCacheFile;
}
if (isset($worker->junitFile)) { if (isset($worker->junitFile)) {
$this->junitFiles[] = $worker->junitFile; $this->junitFiles[] = $worker->junitFile;
} }
@@ -311,6 +362,20 @@ final class WrapperRunner implements RunnerInterface
private function complete(TestResult $testResultSum): int private function complete(TestResult $testResultSum): int
{ {
$missingTestResultFiles = [];
foreach ($this->requiredTestResultFiles as $filePath => $true) {
if (is_file($filePath)) {
continue;
}
$missingTestResultFiles[] = $filePath;
}
if ($missingTestResultFiles !== []) {
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
}
foreach ($this->testResultFiles as $testResultFile) { foreach ($this->testResultFiles as $testResultFile) {
if (! $testResultFile->isFile()) { if (! $testResultFile->isFile()) {
continue; continue;
@@ -345,6 +410,20 @@ final class WrapperRunner implements RunnerInterface
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueDeprecationEvents(), $testResult->testRunnerTriggeredIssueDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueErrorEvents(), $testResult->testRunnerTriggeredIssueErrorEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueNoticeEvents(), $testResult->testRunnerTriggeredIssueNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(), $testResult->testRunnerTriggeredIssuePhpDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(), $testResult->testRunnerTriggeredIssuePhpNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssuePhpWarningEvents(), $testResult->testRunnerTriggeredIssuePhpWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredIssueWarningEvents(), $testResult->testRunnerTriggeredIssueWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->errors(), $testResult->errors()), array_merge_recursive($testResultSum->errors(), $testResult->errors()),
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()), array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
@@ -380,8 +459,15 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->testRunnerTriggeredNoticeEvents(), $testResultSum->testRunnerTriggeredNoticeEvents(),
array_values(array_filter( array_values(array_filter(
$testResultSum->testRunnerTriggeredWarningEvents(), $testResultSum->testRunnerTriggeredWarningEvents(),
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found') fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
)), )),
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
$testResultSum->testRunnerTriggeredIssueNoticeEvents(),
$testResultSum->testRunnerTriggeredIssuePhpDeprecationEvents(),
$testResultSum->testRunnerTriggeredIssuePhpNoticeEvents(),
$testResultSum->testRunnerTriggeredIssuePhpWarningEvents(),
$testResultSum->testRunnerTriggeredIssueWarningEvents(),
$testResultSum->errors(), $testResultSum->errors(),
$testResultSum->deprecations(), $testResultSum->deprecations(),
$testResultSum->notices(), $testResultSum->notices(),
@@ -417,8 +503,11 @@ final class WrapperRunner implements RunnerInterface
$exitcode = Result::exitCode($this->options->configuration, $testResultSum); $exitcode = Result::exitCode($this->options->configuration, $testResultSum);
$this->clearFiles($this->statusFiles);
$this->clearFiles($this->progressFiles);
$this->clearFiles($this->unexpectedOutputFiles); $this->clearFiles($this->unexpectedOutputFiles);
$this->clearFiles($this->testResultFiles); $this->clearFiles($this->testResultFiles);
$this->clearFiles($this->resultCacheFiles);
$this->clearFiles($this->coverageFiles); $this->clearFiles($this->coverageFiles);
$this->clearFiles($this->junitFiles); $this->clearFiles($this->junitFiles);
$this->clearFiles($this->teamcityFiles); $this->clearFiles($this->teamcityFiles);
@@ -433,6 +522,20 @@ final class WrapperRunner implements RunnerInterface
return; return;
} }
$missingCoverageFiles = [];
foreach ($this->requiredCoverageFiles as $filePath => $true) {
if (is_file($filePath) && filesize($filePath) !== 0) {
continue;
}
$missingCoverageFiles[] = $filePath;
}
if ($missingCoverageFiles !== []) {
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
}
$coverageManager = new CodeCoverage; $coverageManager = new CodeCoverage;
$coverageManager->init( $coverageManager->init(
$this->options->configuration, $this->options->configuration,
@@ -448,10 +551,33 @@ final class WrapperRunner implements RunnerInterface
return; return;
} }
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage()); $coverageFiles = [];
foreach ($this->coverageFiles as $coverageFile) { foreach ($this->coverageFiles as $fileInfo) {
$coverageMerger->addCoverageFromFile($coverageFile); $realPath = $fileInfo->getRealPath();
if ($realPath !== false && $realPath !== '') {
$coverageFiles[] = $realPath;
}
} }
$serializedCoverage = (new Merger)->merge($coverageFiles);
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
$serializedCoverage['codeCoverage'],
$serializedCoverage['testResults'],
$serializedCoverage['basePath'],
);
$codeCoverage = $coverageManager->codeCoverage();
$codeCoverage->excludeUncoveredFiles();
$mergedData = $serializedCoverage['codeCoverage'];
$basePath = $serializedCoverage['basePath'];
if ($basePath !== '') {
foreach ($mergedData->coveredFiles() as $relativePath) {
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
}
}
$codeCoverage->setData($mergedData);
$codeCoverage->setTests($serializedCoverage['testResults']);
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
$coverageManager->generateReports( $coverageManager->generateReports(
$this->printer->printer, $this->printer->printer,
@@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Support;
use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\Style; use NunoMaduro\Collision\Adapters\Phpunit\Style;
use ParaTest\Options; use ParaTest\Options;
use PHPUnit\Event\Telemetry\CpuTime;
use PHPUnit\Event\Telemetry\GarbageCollectorStatus; use PHPUnit\Event\Telemetry\GarbageCollectorStatus;
use PHPUnit\Event\Telemetry\HRTime; use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\Info; use PHPUnit\Event\Telemetry\Info;
@@ -147,11 +148,20 @@ final class CompactPrinter
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
$garbageCollectorStatus, $garbageCollectorStatus,
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
), ),
$telemetryDuration, $telemetryDuration,
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
\PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0), \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0),
MemoryUsage::fromBytes(0), MemoryUsage::fromBytes(0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
CpuTime::fromSecondsAndNanoseconds(0, 0),
); );
$this->style->writeRecap($state, $telemetry, $testResult); $this->style->writeRecap($state, $telemetry, $testResult);
+105 -12
View File
@@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
private const string SHARD_OPTION = 'shard'; private const string SHARD_OPTION = 'shard';
/**
* The maximum length allowed for the filter argument.
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
*/
private const int MAX_FILTER_LENGTH = 32768;
/** /**
* The shard index and total number of shards. * The shard index and total number of shards.
* *
@@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
self::$timeBalanced = true; self::$timeBalanced = true;
self::$shardsOutdated = $newTests !== []; self::$shardsOutdated = $newTests !== [];
} else { } else {
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? []; $isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
} }
self::$shard = [ self::$shard = [
@@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
return $arguments; return $arguments;
} }
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)]; $filter = $this->buildFilterArgument($testsToRun);
$this->ensureFilterLengthIsSafe($filter);
return [...$arguments, '--filter', $filter];
} }
/** /**
@@ -187,15 +199,14 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
private function allTests(array $arguments): array private function allTests(array $arguments): array
{ {
$output = (new Process([ $command = $this->buildListTestsCommand(
'php', $arguments,
...$this->removeParallelArguments($arguments), TestSuite::getInstance()->testPath,
'--list-tests', );
]))->setTimeout(120)->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches); $output = new Process($command)->setTimeout(120)->mustRun()->getOutput();
return array_values(array_unique($matches[1])); return $this->parseListTestsOutput($output);
} }
/** /**
@@ -204,15 +215,97 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/ */
private function removeParallelArguments(array $arguments): array private function removeParallelArguments(array $arguments): array
{ {
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)); return array_values(array_filter(
$arguments,
fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)
&& ! str_starts_with($argument, '--processes'),
));
}
/**
* Builds the subprocess command used to enumerate tests via `--list-tests`.
*
* @param list<string> $arguments
* @return list<string>
*/
private function buildListTestsCommand(array $arguments, string $testPath): array
{
$filtered = $this->removeParallelArguments($arguments);
return ['php', ...$filtered, '--test-directory='.$testPath, '--list-tests'];
}
/**
* Parses `--list-tests` output into a unique list of test class FQCNs.
*
* @return list<string>
*/
private function parseListTestsOutput(string $output): array
{
preg_match_all('/ - (?:P\\\\)?([A-Za-z_]\w*(?:\\\\[A-Za-z_]\w*)*)::/', $output, $matches);
return array_values(array_unique($matches[1]));
} }
/** /**
* Builds the filter argument for the given tests to run. * Builds the filter argument for the given tests to run.
*
* @param array<int, string> $testsToRun
*/ */
private function buildFilterArgument(mixed $testsToRun): string private function buildFilterArgument(array $testsToRun): string
{ {
return addslashes(implode('|', $testsToRun)); if ($testsToRun === []) {
return '';
}
/** @var array<string, mixed> $tree */
$tree = [];
foreach ($testsToRun as $class) {
$parts = explode('\\', $class);
$current = &$tree;
foreach ($parts as $part) {
if (! isset($current[$part])) {
$current[$part] = [];
}
$current = &$current[$part];
}
}
$buildRegex = function (array $tree) use (&$buildRegex): string {
$parts = [];
foreach ($tree as $key => $sub) {
$subRegex = $buildRegex($sub);
if ($subRegex === '') {
$parts[] = preg_quote($key, '/');
} else {
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
}
}
return implode('|', $parts);
};
return $buildRegex($tree);
}
/**
* Ensures that the filter length is safe for the current environment.
*
* @throws InvalidOption
*/
private function ensureFilterLengthIsSafe(string $filter): void
{
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
if (strlen($filter) > $maxLength) {
throw new InvalidOption(sprintf(
'The generated filter for this shard is too long (%d characters). '.
'This can cause issues with some environments (limit is %d characters). '.
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
strlen($filter),
$maxLength
));
}
} }
/** /**
+562 -63
View File
File diff suppressed because it is too large Load Diff
+115 -51
View File
@@ -5,14 +5,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency; use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process; use Pest\Support\Git;
/** /**
* @internal * @internal
*/ */
final readonly class ChangedFiles final readonly class ChangedFiles
{ {
public function __construct(private string $projectRoot) {} private Git $git;
public function __construct(private string $projectRoot)
{
$this->git = new Git($projectRoot);
}
/** /**
* @param array<int, string> $files project-relative paths. * @param array<int, string> $files project-relative paths.
@@ -155,15 +160,7 @@ final readonly class ChangedFiles
private function contentAtSha(string $sha, string $path): ?string private function contentAtSha(string $sha, string $path): ?string
{ {
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); return $this->git->show($sha, $path);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process->getOutput();
} }
/** /**
@@ -176,21 +173,17 @@ final readonly class ChangedFiles
return $candidates; return $candidates;
} }
$process = new Process( $result = $this->git->result(
['git', 'check-ignore', '--no-index', '-z', '--stdin'], ['check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot, implode("\x00", array_keys($candidates)),
); );
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode(); // `check-ignore` exits 1 when nothing matched — that is not a failure.
if ($result['exitCode'] !== 0 && $result['exitCode'] !== 1) {
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$output = $process->getOutput(); $output = $result['output'];
if ($output === '') { if ($output === '') {
return $candidates; return $candidates;
@@ -207,27 +200,109 @@ final readonly class ChangedFiles
public function currentBranch(): ?string public function currentBranch(): ?string
{ {
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); $output = $this->git->raw(['rev-parse', '--abbrev-ref', 'HEAD']);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$branch = trim($process->getOutput()); $branch = trim($output);
return $branch === '' || $branch === 'HEAD' ? null : $branch; return $branch === '' || $branch === 'HEAD' ? null : $branch;
} }
public function defaultBranch(): ?string
{
$head = $this->git->output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
if ($head !== null) {
$branch = preg_replace('#^origin/#', '', $head);
if (is_string($branch) && $branch !== '') {
return $branch;
}
}
$configured = $this->git->output(['config', '--get', 'init.defaultBranch']);
if ($configured === null) {
return null;
}
$exists = $this->git->hasRef('refs/heads/'.$configured)
|| $this->git->hasRef('refs/remotes/origin/'.$configured);
return $exists ? $configured : null;
}
/**
* @return list<string>|null
*/
public function branchNames(): ?array
{
$output = $this->git->raw(['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes']);
if ($output === null) {
return null;
}
$names = [];
foreach ($this->splitLines($output) as $ref) {
if (str_starts_with($ref, 'refs/heads/')) {
$names[substr($ref, strlen('refs/heads/'))] = true;
continue;
}
if (! str_starts_with($ref, 'refs/remotes/')) {
continue;
}
$tail = substr($ref, strlen('refs/remotes/'));
$slash = strpos($tail, '/');
if ($slash === false) {
continue;
}
$branch = substr($tail, $slash + 1);
if ($branch !== '' && $branch !== 'HEAD') {
$names[$branch] = true;
}
}
return array_keys($names);
}
public function hasRemote(): bool
{
return $this->git->hasRemote();
}
public function isRepository(): bool
{
return $this->git->isRepository();
}
public function hasCommits(): bool
{
return $this->git->hasCommits();
}
/**
* Working-tree scans get a longer leash than metadata queries — on a large
* repository with a cold cache, `status` and `diff` are not instant.
*/
private function scan(): Git
{
return $this->git->withTimeout(60.0);
}
private function shaIsReachable(string $sha): bool private function shaIsReachable(string $sha): bool
{ {
$process = new Process( return $this->git->succeeds(['merge-base', '--is-ancestor', $sha, 'HEAD']);
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
return $process->getExitCode() === 0;
} }
/** /**
@@ -235,17 +310,13 @@ final readonly class ChangedFiles
*/ */
private function diffSinceSha(string $sha): array private function diffSinceSha(string $sha): array
{ {
$process = new Process( $output = $this->scan()->raw(['diff', '--name-only', '--no-renames', $sha.'..HEAD']);
['git', 'diff', '--name-only', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
return $this->splitLines($process->getOutput()); return $this->splitLines($output);
} }
/** /**
@@ -253,18 +324,12 @@ final readonly class ChangedFiles
*/ */
private function workingTreeChanges(): array private function workingTreeChanges(): array
{ {
$process = new Process( $output = $this->scan()->raw(['status', '--porcelain', '-z', '--untracked-files=all']);
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$output = $process->getOutput();
if ($output === '') { if ($output === '') {
return []; return [];
} }
@@ -302,14 +367,13 @@ final readonly class ChangedFiles
public function currentSha(): ?string public function currentSha(): ?string
{ {
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); $output = $this->git->raw(['rev-parse', 'HEAD']);
$process->run();
if (! $process->isSuccessful()) { if ($output === null) {
throw new MissingDependency('Tia mode', 'git'); throw new MissingDependency('Tia mode', 'git');
} }
$sha = trim($process->getOutput()); $sha = trim($output);
return $sha === '' ? null : $sha; return $sha === '' ? null : $sha;
} }
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\Ci;
/**
* @internal
*/
final class CiDefaultBranch
{
/**
* @var array<int, class-string<Ci>>
*/
private const array CIS = [
Cis\GitLab::class,
Cis\GitHub::class,
];
public static function detect(): ?string
{
foreach (self::CIS as $class) {
$branch = (new $class)->defaultBranch();
if ($branch !== null) {
return $branch;
}
}
return null;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Cis\Concerns;
/**
* @internal
*/
trait ReadsEnvironment
{
private function environment(string $name): ?string
{
$value = getenv($name);
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Cis;
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
use Pest\Plugins\Tia\Contracts\Ci;
/**
* @internal
*/
final readonly class GitHub implements Ci
{
use ReadsEnvironment;
public function defaultBranch(): ?string
{
$path = $this->environment('GITHUB_EVENT_PATH');
if ($path === null || ! is_file($path) || ! is_readable($path)) {
return null;
}
$contents = @file_get_contents($path);
if ($contents === false) {
return null;
}
$payload = json_decode($contents, true);
if (! is_array($payload) || ! is_array($payload['repository'] ?? null)) {
return null;
}
$branch = $payload['repository']['default_branch'] ?? null;
return is_string($branch) && $branch !== '' ? $branch : null;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Cis;
use Pest\Plugins\Tia\Cis\Concerns\ReadsEnvironment;
use Pest\Plugins\Tia\Contracts\Ci;
/**
* @internal
*/
final readonly class GitLab implements Ci
{
use ReadsEnvironment;
public function defaultBranch(): ?string
{
return $this->environment('CI_DEFAULT_BRANCH');
}
}
+12
View File
@@ -60,6 +60,18 @@ final class Configuration
return $this; return $this;
} }
/**
* @return $this
*/
public function defaultBranch(string $branch): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->setDefaultBranch($branch);
return $this;
}
/** /**
* @param array<string, string> $patterns glob → project-relative test dir * @param array<string, string> $patterns glob → project-relative test dir
* @return $this * @return $this
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface Ci
{
/**
* The default branch advertised by this CI, or `null` when the run
* does not happen on it — or when it exposes no such information.
*/
public function defaultBranch(): ?string;
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface Lockfile
{
public function applies(string $filename): bool;
public function fingerprint(string $contents): ?string;
}
+57 -1
View File
@@ -8,6 +8,9 @@ use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container; use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
use Throwable; use Throwable;
/** /**
@@ -161,7 +164,60 @@ final class CoverageMerger
return null; return null;
} }
return $value instanceof CodeCoverage ? $value : null; // Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
if ($value instanceof CodeCoverage) {
return $value;
}
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
// be rebuilt into one before it can be merged.
return self::coverageFromSerializedData($reportPath);
}
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
{
if ($reportPath === '') {
return null;
}
try {
$serialized = new Unserializer()->unserialize($reportPath);
} catch (Throwable) {
return null;
}
$data = $serialized['codeCoverage'];
$basePath = $serialized['basePath'];
if ($basePath !== '') {
foreach ($data->coveredFiles() as $relativePath) {
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
}
}
$coverage = self::emptyCoverage();
if (! $coverage instanceof CodeCoverage) {
return null;
}
$coverage->setData($data);
$coverage->setTests($serialized['testResults']);
return $coverage;
}
private static function emptyCoverage(): ?CodeCoverage
{
try {
$filter = new Filter;
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
} catch (Throwable) {
return null;
}
} }
private static function unserializeCoverage(string $bytes): ?CodeCoverage private static function unserializeCoverage(string $bytes): ?CodeCoverage
+3 -1
View File
@@ -29,7 +29,9 @@ enum ReplayType
$status->isRisky() => self::Risky, $status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped, $status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete, $status->isIncomplete() => self::Incomplete,
default => self::Failure, $status->isNotice(), $status->isDeprecation(), $status->isWarning() => self::Pass,
$status->isFailure(), $status->isError() => self::Failure,
default => self::None,
}; };
} }
} }
+4
View File
@@ -89,6 +89,10 @@ final class FileState implements State
$keys = []; $keys = [];
foreach ($matches as $path) { foreach ($matches as $path) {
if (str_ends_with($path, '.tmp')) {
continue;
}
$keys[] = basename($path); $keys[] = basename($path);
} }
+45 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\Lockfile;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
/** /**
@@ -11,7 +12,14 @@ use Symfony\Component\Finder\Finder;
*/ */
final readonly class Fingerprint final readonly class Fingerprint
{ {
private const int SCHEMA_VERSION = 17; private const int SCHEMA_VERSION = 18;
/**
* @var array<int, class-string<Lockfile>>
*/
private const array LOCKFILES = [
Lockfiles\PackageLock::class,
];
/** /**
* @return array{ * @return array{
@@ -205,7 +213,11 @@ final readonly class Fingerprint
$parts = []; $parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) { foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::trackedHash($projectRoot, $name); if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::lockfileHash($projectRoot.'/'.$name, $name);
if ($hash !== null) { if ($hash !== null) {
$parts[] = $name.':'.$hash; $parts[] = $name.':'.$hash;
@@ -215,6 +227,37 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
} }
private static function lockfileHash(string $path, string $name): ?string
{
if (! is_file($path)) {
return null;
}
$contents = @file_get_contents($path);
if ($contents === false) {
return null;
}
foreach (self::LOCKFILES as $class) {
$handler = new $class;
if (! $handler->applies($name)) {
continue;
}
$fingerprint = $handler->fingerprint($contents);
if ($fingerprint !== null) {
return $fingerprint;
}
break;
}
return hash('xxh128', $contents);
}
private static function trackedHash(string $projectRoot, string $relativePath): ?string private static function trackedHash(string $projectRoot, string $relativePath): ?string
{ {
if (! self::isTrackedByGit($projectRoot, $relativePath)) { if (! self::isTrackedByGit($projectRoot, $relativePath)) {
+401 -31
View File
@@ -43,11 +43,14 @@ final class Graph
* @var array<string, array{ * @var array<string, array{
* sha: ?string, * sha: ?string,
* tree: array<string, string>, * tree: array<string, string>,
* complete?: bool,
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> * results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
* }> * }>
*/ */
private array $baselines = []; private array $baselines = [];
private string $fallbackBranch = 'main';
private readonly string $projectRoot; private readonly string $projectRoot;
/** @var array<string, true>|null */ /** @var array<string, true>|null */
@@ -116,6 +119,24 @@ final class Graph
return array_keys($affectedSet); return array_keys($affectedSet);
} }
/**
* @param array<int, string> $testFiles Project-relative paths.
* @return list<string>
*/
public function testFilesOnDisk(array $testFiles): array
{
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$onDisk = [];
foreach ($testFiles as $testFile) {
if (is_file($root.$testFile)) {
$onDisk[] = $testFile;
}
}
return $onDisk;
}
/** /**
* @param array<int, string> $changedFiles * @param array<int, string> $changedFiles
* @return array{0: list<string>, 1: list<string>} * @return array{0: list<string>, 1: list<string>}
@@ -149,6 +170,13 @@ final class Graph
*/ */
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
{ {
// With no recorded table usage at all, table intersection can never
// select anything — route every migration change through the
// watch-pattern fallback instead of silently skipping tests.
if ($this->testTables === []) {
return $migrationPaths;
}
$changedTables = []; $changedTables = [];
$unparseable = []; $unparseable = [];
@@ -446,13 +474,14 @@ final class Graph
$bladeAffected = $this->affectedByStaticBladeUsage($rel); $bladeAffected = $this->affectedByStaticBladeUsage($rel);
// Only a walk that actually selected tests counts as handled — a
// component whose usage the static walk missed must still reach
// the watch-pattern fallback instead of being silently swallowed.
if ($bladeAffected !== []) { if ($bladeAffected !== []) {
foreach ($bladeAffected as $testFile) { foreach ($bladeAffected as $testFile) {
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
$staticallyHandled[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) {
$staticallyHandled[$rel] = true; $staticallyHandled[$rel] = true;
} }
} }
@@ -568,7 +597,12 @@ final class Graph
return $this->fingerprint; return $this->fingerprint;
} }
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string public function setFallbackBranch(string $branch): void
{
$this->fallbackBranch = $branch;
}
public function recordedAtSha(string $branch, ?string $fallbackBranch = null): ?string
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -603,7 +637,7 @@ final class Graph
$this->baselines[$branch]['results'][$testId] = $entry; $this->baselines[$branch]['results'][$testId] = $entry;
} }
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int public function getAssertions(string $branch, string $testId, ?string $fallbackBranch = null): ?int
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -614,7 +648,18 @@ final class Graph
return $baseline['results'][$testId]['assertions']; return $baseline['results'][$testId]['assertions'];
} }
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus public function getTime(string $branch, string $testId, ?string $fallbackBranch = null): ?float
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
if (! isset($baseline['results'][$testId]['time'])) {
return null;
}
return $baseline['results'][$testId]['time'];
}
public function getResult(string $branch, string $testId, ?string $fallbackBranch = null): ?TestStatus
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -634,14 +679,14 @@ final class Graph
6 => TestStatus::warning($r['message']), 6 => TestStatus::warning($r['message']),
7 => TestStatus::failure($r['message']), 7 => TestStatus::failure($r['message']),
8 => TestStatus::error($r['message']), 8 => TestStatus::error($r['message']),
default => TestStatus::unknown(), default => null,
}; };
} }
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array public function testFilesToRerun(string $branch, ?string $fallbackBranch = null): array
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
$files = []; $files = [];
@@ -661,7 +706,7 @@ final class Graph
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel !== null) { if ($rel !== null && is_file($this->projectRoot.'/'.$rel)) {
$files[$rel] = true; $files[$rel] = true;
} }
} }
@@ -669,7 +714,7 @@ final class Graph
return array_keys($files); return array_keys($files);
} }
public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@@ -680,7 +725,11 @@ final class Graph
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if ($file === null || $file === '' || $this->relative($file) === null) { if ($file === null || $file === '') {
return true;
}
if ($this->relative($file) === null) {
return true; return true;
} }
} }
@@ -690,12 +739,24 @@ final class Graph
private function shouldRerun(int $status): bool private function shouldRerun(int $status): bool
{ {
$testStatus = TestStatus::from($status); return $this->shouldRerunStatus(TestStatus::from($status));
}
/**
* Whether a cached result with this status must be re-executed rather
* than replayed, honouring the configured failOn* / displayDetailsOn*
* policies.
*/
public function shouldRerunStatus(TestStatus $testStatus): bool
{
if ($testStatus->isFailure() || $testStatus->isError()) { if ($testStatus->isFailure() || $testStatus->isError()) {
return true; return true;
} }
if ($testStatus->isUnknown()) {
return true;
}
$configuration = Registry::get(); $configuration = Registry::get();
if ($testStatus->isRisky()) { if ($testStatus->isRisky()) {
@@ -763,25 +824,70 @@ final class Graph
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array public function lastRunTree(string $branch, ?string $fallbackBranch = null): array
{ {
return $this->baselineFor($branch, $fallbackBranch)['tree']; return $this->baselineFor($branch, $fallbackBranch)['tree'];
} }
/** /**
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>} * @return array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
*/ */
private function baselineFor(string $branch, string $fallbackBranch): array private function baselineFor(string $branch, ?string $fallbackBranch): array
{ {
if (isset($this->baselines[$branch])) { $fallbackBranch ??= $this->fallbackBranch;
return $this->baselines[$branch];
$fallback = $branch !== $fallbackBranch ? ($this->baselines[$fallbackBranch] ?? null) : null;
$own = $this->baselines[$branch] ?? null;
if ($own === null) {
return $fallback ?? ['sha' => null, 'tree' => [], 'results' => []];
} }
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) { if ($fallback === null) {
return $this->baselines[$fallbackBranch]; return $own;
} }
return ['sha' => null, 'tree' => [], 'results' => []]; $under = ($own['complete'] ?? false) === true
? $this->withoutFilesCoveredBy($fallback['results'], $own['results'])
: $fallback['results'];
return [
'sha' => $own['sha'] ?? $fallback['sha'],
'tree' => $own['tree'] !== [] ? $own['tree'] : $fallback['tree'],
'results' => array_replace($under, $own['results']),
];
}
/**
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $results
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $authoritative
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
*/
private function withoutFilesCoveredBy(array $results, array $authoritative): array
{
$covered = [];
foreach ($authoritative as $entry) {
$file = $entry['file'] ?? null;
if (is_string($file) && $file !== '') {
$covered[$file] = true;
}
}
if ($covered === []) {
return $results;
}
foreach ($results as $testId => $entry) {
$file = $entry['file'] ?? null;
if (is_string($file) && isset($covered[$file])) {
unset($results[$testId]);
}
}
return $results;
} }
private function ensureBaseline(string $branch): void private function ensureBaseline(string $branch): void
@@ -793,8 +899,9 @@ final class Graph
/** /**
* @param array<string, array<int, string>> $testToFiles * @param array<string, array<int, string>> $testToFiles
* @param bool $keepExisting Leave already-recorded edge sets alone.
*/ */
public function replaceEdges(array $testToFiles): void public function replaceEdges(array $testToFiles, bool $keepExisting = false): void
{ {
foreach ($testToFiles as $testFile => $sources) { foreach ($testToFiles as $testFile => $sources) {
$testRel = $this->relative($testFile); $testRel = $this->relative($testFile);
@@ -803,6 +910,10 @@ final class Graph
continue; continue;
} }
if ($keepExisting && ($this->edges[$testRel] ?? []) !== []) {
continue;
}
$this->edges[$testRel] = []; $this->edges[$testRel] = [];
foreach ($sources as $source) { foreach ($sources as $source) {
@@ -813,6 +924,38 @@ final class Graph
} }
} }
/**
* Mark test files that executed under a recorded coverage session as "known",
* seeding an empty edge set for any that produced zero project-source edges.
*
* Without this, a test that covers no application source (e.g. a pure unit
* test asserting on language primitives) never becomes an edge key, so
* {@see self::knowsTest()} reports it as unknown and it re-runs on every TIA
* run. Recording it with an empty edge set lets it be replayed/skipped while
* unchanged; it is still re-run whenever its own file changes, via
* {@see self::applyTestFileChanges()}.
*
* Must only be called from the recording path, where coverage was actually
* collected — otherwise a missing edge set could mean "coverage was off",
* not "genuinely covered nothing".
*
* @param array<int, string> $testFiles Absolute or project-relative test file paths.
*/
public function markKnownTestFiles(array $testFiles): void
{
foreach ($testFiles as $testFile) {
$rel = $this->relative($testFile);
if ($rel === null) {
continue;
}
if (! isset($this->edges[$rel])) {
$this->edges[$rel] = [];
}
}
}
/** /**
* @param array<string, array<int, string>> $testToTables * @param array<string, array<int, string>> $testToTables
*/ */
@@ -1217,7 +1360,21 @@ final class Graph
$tail = substr($tail, 0, -strlen('.blade.php')); $tail = substr($tail, 0, -strlen('.blade.php'));
$name = str_replace('/', '.', $tail); $name = str_replace('/', '.', $tail);
return $name === '' ? [] : [$name, str_replace('_', '-', $name)]; if ($name === '') {
return [];
}
$names = [$name, str_replace('_', '-', $name)];
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
if (str_ends_with($name, '.index') && $name !== '.index') {
$base = substr($name, 0, -strlen('.index'));
$names[] = $base;
$names[] = str_replace('_', '-', $base);
}
return array_values(array_unique($names));
} }
/** @return list<string> */ /** @return list<string> */
@@ -1289,13 +1446,7 @@ final class Graph
/** @param array<string, array<int, string>> $edges */ /** @param array<string, array<int, string>> $edges */
private function anyTestUses(array $edges, string $component): bool private function anyTestUses(array $edges, string $component): bool
{ {
foreach ($edges as $components) { return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
if (in_array($component, $components, true)) {
return true;
}
}
return false;
} }
public function pruneMissingTests(): void public function pruneMissingTests(): void
@@ -1321,6 +1472,56 @@ final class Graph
} }
} }
public function markBaselineComplete(string $branch): void
{
if (isset($this->baselines[$branch])) {
$this->baselines[$branch]['complete'] = true;
}
}
public function pruneResultsForMissingFiles(string $branch): void
{
if (! isset($this->baselines[$branch]['results'])) {
return;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
$file = $result['file'] ?? null;
if (! is_string($file)) {
continue;
}
if ($file === '') {
continue;
}
$rel = $this->relative($file);
if ($rel === null) {
continue;
}
if (is_file($root.$rel)) {
continue;
}
unset($this->baselines[$branch]['results'][$testId]);
}
}
/**
* @param array<int, string> $keep
*/
public function pruneMissingBranches(array $keep): void
{
$survivors = array_fill_keys($keep, true);
foreach (array_keys($this->baselines) as $branch) {
if (! isset($survivors[$branch])) {
unset($this->baselines[$branch]);
}
}
}
/** /**
* Prune baseline result entries whose test files were just executed but whose * Prune baseline result entries whose test files were just executed but whose
* test IDs are no longer present (e.g. the test method was removed or renamed). * test IDs are no longer present (e.g. the test method was removed or renamed).
@@ -1366,6 +1567,28 @@ final class Graph
} }
} }
/**
* @return list<string>
*/
public static function branchesIn(string $json): array
{
$data = json_decode($json, true);
if (! is_array($data) || ! is_array($data['baselines'] ?? null)) {
return [];
}
$branches = [];
foreach (array_keys($data['baselines']) as $branch) {
if (is_string($branch) && $branch !== '') {
$branches[] = $branch;
}
}
return $branches;
}
public static function decode(string $json, string $projectRoot): ?self public static function decode(string $json, string $projectRoot): ?self
{ {
$data = json_decode($json, true); $data = json_decode($json, true);
@@ -1376,10 +1599,10 @@ final class Graph
$graph = new self($projectRoot); $graph = new self($projectRoot);
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : []; $graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : []; $graph->files = self::decodeFiles($data['files'] ?? null);
$graph->fileIds = array_flip($graph->files); $graph->fileIds = array_flip($graph->files);
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; $graph->edges = self::decodeEdges($data['edges'] ?? null);
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : []; $graph->baselines = self::decodeBaselines($data['baselines'] ?? null);
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null); $graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null); $graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
@@ -1388,6 +1611,153 @@ final class Graph
return $graph; return $graph;
} }
/**
* @return array<int, string>
*/
private static function decodeFiles(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$files = [];
foreach ($section as $path) {
if (is_string($path) && $path !== '') {
$files[] = $path;
}
}
return $files;
}
/**
* @return array<string, array<int, int>>
*/
private static function decodeEdges(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$edges = [];
foreach ($section as $key => $ids) {
$testFile = (string) $key;
if ($testFile === '') {
continue;
}
if (! is_array($ids)) {
continue;
}
$clean = [];
foreach ($ids as $id) {
if (is_int($id)) {
$clean[] = $id;
}
}
$edges[$testFile] = $clean;
}
return $edges;
}
/**
* @return array<string, array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}>
*/
private static function decodeBaselines(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$baselines = [];
foreach ($section as $key => $baseline) {
$branch = (string) $key;
if ($branch === '') {
continue;
}
if (! is_array($baseline)) {
continue;
}
$sha = $baseline['sha'] ?? null;
$tree = [];
if (is_array($baseline['tree'] ?? null)) {
foreach ($baseline['tree'] as $path => $hash) {
if (is_string($path) && is_string($hash)) {
$tree[$path] = $hash;
}
}
}
$baselines[$branch] = [
'sha' => is_string($sha) ? $sha : null,
'tree' => $tree,
'results' => self::decodeResults($baseline['results'] ?? null),
];
if (($baseline['complete'] ?? null) === true) {
$baselines[$branch]['complete'] = true;
}
}
return $baselines;
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
*/
private static function decodeResults(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$results = [];
foreach ($section as $key => $entry) {
$testId = (string) $key;
if ($testId === '') {
continue;
}
if (! is_array($entry)) {
continue;
}
if (! is_int($entry['status'] ?? null)) {
continue;
}
$time = $entry['time'] ?? null;
$result = [
'status' => $entry['status'],
'message' => is_string($entry['message'] ?? null) ? $entry['message'] : '',
'time' => is_int($time) || is_float($time) ? (float) $time : 0.0,
];
if (is_int($entry['assertions'] ?? null)) {
$result['assertions'] = $entry['assertions'];
}
if (is_string($entry['file'] ?? null) && $entry['file'] !== '') {
$result['file'] = $entry['file'];
}
$results[$testId] = $result;
}
return $results;
}
/** /**
* @return array<string, list<string>> * @return array<string, list<string>>
*/ */
+4 -8
View File
@@ -163,7 +163,9 @@ final class JsModuleGraph
return null; return null;
} }
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) { $nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
return null; return null;
} }
@@ -386,12 +388,6 @@ final class JsModuleGraph
private static function hasViteConfig(string $projectRoot): bool private static function hasViteConfig(string $projectRoot): bool
{ {
foreach (self::VITE_CONFIG_NAMES as $name) { return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
}
}
return false;
} }
} }
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Lockfiles;
use Pest\Plugins\Tia\Contracts\Lockfile;
/**
* @internal
*/
final readonly class PackageLock implements Lockfile
{
public function applies(string $filename): bool
{
return $filename === 'package-lock.json';
}
public function fingerprint(string $contents): ?string
{
$data = json_decode($contents, true);
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
return null;
}
$packages = $data['packages'];
$platformPaths = [];
foreach ($packages as $path => $meta) {
if (is_string($path) && is_array($meta) && $this->isPlatformSpecific($meta)) {
$platformPaths[] = $path;
}
}
$entries = [];
foreach ($packages as $path => $meta) {
if (! is_string($path)) {
continue;
}
if (! is_array($meta)) {
continue;
}
if ($this->isPlatformSpecific($meta)) {
continue;
}
if ($this->isBundledUnderPlatform($path, $platformPaths)) {
continue;
}
$version = $meta['version'] ?? null;
$entries[$path] = is_string($version) ? $version : '';
}
ksort($entries);
$encoded = json_encode($entries, JSON_UNESCAPED_SLASHES);
return $encoded === false ? null : hash('xxh128', $encoded);
}
/**
* @param array<string, mixed> $meta
*/
private function isPlatformSpecific(array $meta): bool
{
return isset($meta['os']) || isset($meta['cpu']) || isset($meta['libc']);
}
/**
* @param list<string> $platformPaths
*/
private function isBundledUnderPlatform(string $path, array $platformPaths): bool
{
return array_any($platformPaths, fn (string $platformPath): bool => str_starts_with($path, $platformPath.'/node_modules/'));
}
}
+43 -5
View File
@@ -34,6 +34,8 @@ final class Recorder
private bool $active = false; private bool $active = false;
private bool $captureCoverage = false;
private bool $driverChecked = false; private bool $driverChecked = false;
private bool $driverAvailable = false; private bool $driverAvailable = false;
@@ -43,6 +45,17 @@ final class Recorder
private ?SourceScope $sourceScope = null; private ?SourceScope $sourceScope = null;
public function activate(): void public function activate(): void
{
$this->active = true;
$this->captureCoverage = true;
}
/**
* Enable per-test link tracking (tables, Inertia components, database
* usage, rendered views) without driving pcov/xdebug — for runs where
* coverage edges are piggybacked from an existing PHPUnit coverage session.
*/
public function activateLinkTracking(): void
{ {
$this->active = true; $this->active = true;
} }
@@ -55,7 +68,7 @@ final class Recorder
public function driverAvailable(): bool public function driverAvailable(): bool
{ {
if (! $this->driverChecked) { if (! $this->driverChecked) {
if (function_exists('pcov\\start')) { if (function_exists('pcov\\start') && filter_var((string) ini_get('pcov.enabled'), FILTER_VALIDATE_BOOL)) {
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
@@ -75,7 +88,11 @@ final class Recorder
public function beginTest(string $className, string $methodName, string $fallbackFile): void public function beginTest(string $className, string $methodName, string $fallbackFile): void
{ {
if (! $this->active || ! $this->driverAvailable()) { if (! $this->active) {
return;
}
if ($this->captureCoverage && ! $this->driverAvailable()) {
return; return;
} }
@@ -95,6 +112,10 @@ final class Recorder
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
} }
if (! $this->captureCoverage) {
return;
}
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\clear(); \pcov\clear();
\pcov\start(); \pcov\start();
@@ -107,7 +128,13 @@ final class Recorder
public function endTest(): void public function endTest(): void
{ {
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) { if (! $this->active || $this->currentTestFile === null) {
return;
}
if (! $this->captureCoverage || ! $this->driverAvailable()) {
$this->currentTestFile = null;
return; return;
} }
@@ -132,7 +159,15 @@ final class Recorder
$data = \xdebug_get_code_coverage(); $data = \xdebug_get_code_coverage();
\xdebug_stop_code_coverage(true); \xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data); $scope = $this->sourceScope();
foreach (array_keys($data) as $file) {
if (! $scope->contains($file)) {
unset($data[$file]);
}
}
$coveredFiles = $this->filesWithExecutedLines($data);
} }
foreach ($coveredFiles as $sourceFile) { foreach ($coveredFiles as $sourceFile) {
@@ -325,7 +360,9 @@ final class Recorder
} }
$lineKeys = array_keys($lines); $lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) { $reportsUnexecutedLines = count($covered) < count($lines);
if ($reportsUnexecutedLines && $lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue; continue;
} }
@@ -351,5 +388,6 @@ final class Recorder
$this->classUsesDatabaseCache = []; $this->classUsesDatabaseCache = [];
$this->sourceScope = null; $this->sourceScope = null;
$this->active = false; $this->active = false;
$this->captureCoverage = false;
} }
} }
+62
View File
@@ -16,6 +16,9 @@ final class ResultCollector
*/ */
private array $results = []; private array $results = [];
/** @var array<string, true> */
private array $triggered = [];
private ?string $currentTestId = null; private ?string $currentTestId = null;
private ?string $currentTestFile = null; private ?string $currentTestFile = null;
@@ -35,9 +38,30 @@ final class ResultCollector
return; return;
} }
if (isset($this->triggered[$this->currentTestId])) {
$this->refreshTime();
return;
}
$this->record(TestStatus::success()); $this->record(TestStatus::success());
} }
public function testTriggeredNotice(string $message): void
{
$this->recordIssue(TestStatus::notice($message));
}
public function testTriggeredDeprecation(string $message): void
{
$this->recordIssue(TestStatus::deprecation($message));
}
public function testTriggeredWarning(string $message): void
{
$this->recordIssue(TestStatus::warning($message));
}
public function testFailed(string $message): void public function testFailed(string $message): void
{ {
if ($this->currentTestId === null) { if ($this->currentTestId === null) {
@@ -91,6 +115,11 @@ final class ResultCollector
return $this->results; return $this->results;
} }
public function hasUnfinishedTest(): bool
{
return $this->currentTestId !== null;
}
public function recordAssertions(string $testId, int $assertions): void public function recordAssertions(string $testId, int $assertions): void
{ {
if (isset($this->results[$testId])) { if (isset($this->results[$testId])) {
@@ -111,6 +140,7 @@ final class ResultCollector
public function reset(): void public function reset(): void
{ {
$this->results = []; $this->results = [];
$this->triggered = [];
$this->currentTestId = null; $this->currentTestId = null;
$this->currentTestFile = null; $this->currentTestFile = null;
$this->startTime = null; $this->startTime = null;
@@ -123,6 +153,38 @@ final class ResultCollector
$this->startTime = null; $this->startTime = null;
} }
private function recordIssue(TestStatus $status): void
{
if ($this->currentTestId === null) {
return;
}
$existing = $this->results[$this->currentTestId]['status'] ?? null;
if (is_int($existing) && $existing >= $status->asInt()) {
return;
}
$this->triggered[$this->currentTestId] = true;
$this->record($status);
}
private function refreshTime(): void
{
if ($this->currentTestId === null) {
return;
}
if (! isset($this->results[$this->currentTestId])) {
return;
}
if ($this->startTime === null) {
return;
}
$this->results[$this->currentTestId]['time'] = round(microtime(true) - $this->startTime, 3);
}
private function record(TestStatus $status): void private function record(TestStatus $status): void
{ {
if ($this->currentTestId === null) { if ($this->currentTestId === null) {
+70 -44
View File
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
*/ */
final class TableExtractor final class TableExtractor
{ {
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete']; private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
/**
* A single (optionally quoted) identifier segment.
*/
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
/** /**
* @return list<string> Sorted, deduped table names referenced by the * @return list<string> Sorted, deduped table names referenced by the
@@ -22,22 +27,15 @@ final class TableExtractor
return []; return [];
} }
$prefix = strtolower(substr($trimmed, 0, 6)); if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
$matched = false;
foreach (self::DML_PREFIXES as $dml) {
if (str_starts_with($prefix, $dml)) {
$matched = true;
break;
}
}
if (! $matched) {
return []; return [];
} }
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i'; if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
return [];
}
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
if (preg_match_all($pattern, $sql, $matches) === false) { if (preg_match_all($pattern, $sql, $matches) === false) {
return []; return [];
@@ -45,14 +43,9 @@ final class TableExtractor
$tables = []; $tables = [];
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) { foreach ($matches[1] as $qualified) {
$name = $matches[1][$i] !== '' $name = self::unqualified($qualified);
? $matches[1][$i]
: ($matches[2][$i] !== ''
? $matches[2][$i]
: ($matches[3][$i] !== ''
? $matches[3][$i]
: $matches[4][$i]));
if ($name === '') { if ($name === '') {
continue; continue;
} }
@@ -63,7 +56,7 @@ final class TableExtractor
$tables[strtolower($name)] = true; $tables[strtolower($name)] = true;
} }
$out = array_keys($tables); $out = array_map(strval(...), array_keys($tables));
sort($out); sort($out);
return $out; return $out;
@@ -80,51 +73,84 @@ final class TableExtractor
if (preg_match_all($schemaPattern, $php, $matches) !== false) { if (preg_match_all($schemaPattern, $php, $matches) !== false) {
foreach ($matches[1] as $i => $primary) { foreach ($matches[1] as $i => $primary) {
$tables[strtolower($primary)] = true; $tables[strtolower(self::lastDottedSegment($primary))] = true;
$secondary = $matches[2][$i] ?? ''; $secondary = $matches[2][$i] ?? '';
if ($secondary !== '') { if ($secondary !== '') {
$tables[strtolower($secondary)] = true; $tables[strtolower(self::lastDottedSegment($secondary))] = true;
} }
} }
} }
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i'; $qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
if (preg_match_all($ddlPattern, $php, $matches) !== false) { $sqlPatterns = [
foreach ($matches[1] as $primary) { '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
$lower = strtolower($primary); '/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
if (! self::isSchemaMeta($lower)) { '/UPDATE\s+'.$qualified.'\s+SET\b/i',
$tables[$lower] = true; '/DELETE\s+FROM\s+'.$qualified.'/i',
}
}
}
$dmlPatterns = [
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
]; ];
foreach ($dmlPatterns as $pattern) { foreach ($sqlPatterns as $pattern) {
if (preg_match_all($pattern, $php, $matches) === false) { if (preg_match_all($pattern, $php, $matches) === false) {
continue; continue;
} }
foreach ($matches[1] as $name) { foreach ($matches[1] as $name) {
$lower = strtolower($name); $lower = strtolower(self::unqualified($name));
if (! self::isSchemaMeta($lower)) { if ($lower !== '' && ! self::isSchemaMeta($lower)) {
$tables[$lower] = true; $tables[$lower] = true;
} }
} }
} }
$out = array_keys($tables); if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
foreach ($matches[1] as $name) {
$lower = strtolower(self::lastDottedSegment($name));
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$out = array_map(strval(...), array_keys($tables));
sort($out); sort($out);
return $out; return $out;
} }
/**
* The table segment of a possibly schema-qualified identifier chain,
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
*/
private static function unqualified(string $qualified): string
{
$name = '';
foreach (explode('.', $qualified) as $segment) {
$segment = trim($segment, " \t\n\r\"`[]");
if ($segment === '') {
continue;
}
if (self::isSchemaMeta($segment)) {
return '';
}
$name = $segment;
}
return $name;
}
private static function lastDottedSegment(string $name): string
{
$position = strrpos($name, '.');
return $position === false ? $name : substr($name, $position + 1);
}
private static function isSchemaMeta(string $name): bool private static function isSchemaMeta(string $name): bool
{ {
$lower = strtolower($name); $lower = strtolower($name);
+3 -15
View File
@@ -45,11 +45,7 @@ final readonly class TestPaths
$directories[] = $rel; $directories[] = $rel;
} }
$suffix = $directory->suffix(); $suffixes[] = $directory->suffix();
if ($suffix !== '') {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
} }
foreach ($suite->files() as $file) { foreach ($suite->files() as $file) {
@@ -63,7 +59,7 @@ final readonly class TestPaths
if ($suffixes === []) { if ($suffixes === []) {
foreach ($configuration->testSuffixes() as $suffix) { foreach ($configuration->testSuffixes() as $suffix) {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix; $suffixes[] = $suffix;
} }
} }
} catch (Throwable) { } catch (Throwable) {
@@ -94,15 +90,7 @@ final readonly class TestPaths
if (in_array($relativePath, $this->files, true)) { if (in_array($relativePath, $this->files, true)) {
return true; return true;
} }
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
$matchesSuffix = false;
foreach ($this->suffixes as $suffix) {
if (str_ends_with($relativePath, $suffix)) {
$matchesSuffix = true;
break;
}
}
if (! $matchesSuffix) { if (! $matchesSuffix) {
return false; return false;
+16 -21
View File
@@ -44,6 +44,8 @@ final class WatchPatterns
private bool $baselined = false; private bool $baselined = false;
private ?string $defaultBranch = null;
public function useDefaults(string $projectRoot): void public function useDefaults(string $projectRoot): void
{ {
$testPath = TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
@@ -177,6 +179,16 @@ final class WatchPatterns
return $this->baselined; return $this->baselined;
} }
public function setDefaultBranch(string $branch): void
{
$this->defaultBranch = $branch;
}
public function defaultBranch(): ?string
{
return $this->defaultBranch;
}
public function reset(): void public function reset(): void
{ {
$this->patterns = []; $this->patterns = [];
@@ -185,6 +197,7 @@ final class WatchPatterns
$this->locally = false; $this->locally = false;
$this->filtered = false; $this->filtered = false;
$this->baselined = false; $this->baselined = false;
$this->defaultBranch = null;
} }
private function keyMatches(string $key, string $file): bool private function keyMatches(string $key, string $file): bool
@@ -253,35 +266,17 @@ final class WatchPatterns
private function patternTargetsDotfiles(string $pattern): bool private function patternTargetsDotfiles(string $pattern): bool
{ {
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) { return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
} }
private function touchesVcs(string $file): bool private function touchesVcs(string $file): bool
{ {
foreach (explode('/', $file) as $segment) { return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
if (in_array($segment, self::VCS_DIRS, true)) {
return true;
}
}
return false;
} }
private function touchesDotfile(string $file): bool private function touchesDotfile(string $file): bool
{ {
foreach (explode('/', $file) as $segment) { return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
} }
private function excludeMatches(string $exclude, string $file): bool private function excludeMatches(string $exclude, string $file): bool
+1 -1
View File
@@ -37,7 +37,7 @@ final class XdebugRestarter implements Restarter
return; return;
} }
(new XdebugHandler('pest'))->check(); new XdebugHandler('pest')->check();
} }
private function xdebugIsCoverageOnly(): bool private function xdebugIsCoverageOnly(): bool
+15 -4
View File
@@ -15,15 +15,20 @@ use Symfony\Component\Console\Output\OutputInterface;
/** /**
* @internal * @internal
*/ */
final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber final class EnsureTeamCityEnabled implements ConfiguredSubscriber
{ {
/**
* Indicates if the TeamCity logger has already been registered.
*/
private static bool $registered = false;
/** /**
* Creates a new Configured Subscriber instance. * Creates a new Configured Subscriber instance.
*/ */
public function __construct( public function __construct(
private InputInterface $input, private readonly InputInterface $input,
private OutputInterface $output, private readonly OutputInterface $output,
private TestSuite $testSuite, private readonly TestSuite $testSuite,
) {} ) {}
/** /**
@@ -31,10 +36,16 @@ final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
*/ */
public function notify(Configured $event): void public function notify(Configured $event): void
{ {
if (self::$registered) {
return;
}
if (! $this->input->hasParameterOption('--teamcity')) { if (! $this->input->hasParameterOption('--teamcity')) {
return; return;
} }
self::$registered = true;
$flowId = getenv('FLOW_ID'); $flowId = getenv('FLOW_ID');
$flowId = is_string($flowId) ? (int) $flowId : getmypid(); $flowId = is_string($flowId) ? (int) $flowId : getmypid();
@@ -22,7 +22,7 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
if ($test instanceof TestMethod) { if ($test instanceof TestMethod) {
$this->collector->recordAssertions( $this->collector->recordAssertions(
$test->className().'::'.$test->methodName(), $test->id(),
$event->numberOfAssertionsPerformed(), $event->numberOfAssertionsPerformed(),
); );
} }
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\DeprecationTriggered;
use PHPUnit\Event\Test\DeprecationTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnDeprecationTriggered implements DeprecationTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(DeprecationTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredDeprecation($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\NoticeTriggered;
use PHPUnit\Event\Test\NoticeTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnNoticeTriggered implements NoticeTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(NoticeTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredNotice($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\PhpDeprecationTriggered;
use PHPUnit\Event\Test\PhpDeprecationTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPhpDeprecationTriggered implements PhpDeprecationTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PhpDeprecationTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredDeprecation($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\PhpNoticeTriggered;
use PHPUnit\Event\Test\PhpNoticeTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPhpNoticeTriggered implements PhpNoticeTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PhpNoticeTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredNotice($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\PhpWarningTriggered;
use PHPUnit\Event\Test\PhpWarningTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnPhpWarningTriggered implements PhpWarningTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(PhpWarningTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredWarning($event->message());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\WarningTriggered;
use PHPUnit\Event\Test\WarningTriggeredSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaResultIsRecordedOnWarningTriggered implements WarningTriggeredSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(WarningTriggered $event): void
{
if ($event->wasSuppressed()) {
return;
}
$this->collector->testTriggeredWarning($event->message());
}
}
@@ -21,7 +21,7 @@ final readonly class EnsureTiaResultsAreCollected implements PreparationStartedS
$test = $event->test(); $test = $event->test();
if ($test instanceof TestMethod) { if ($test instanceof TestMethod) {
$this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file()); $this->collector->testPrepared($test->id(), $test->file());
} }
} }
} }
+12 -3
View File
@@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Report\Facade;
use SebastianBergmann\Environment\Runtime; use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -95,10 +96,18 @@ final class Coverage
$codeCoverage = require $reportPath; $codeCoverage = require $reportPath;
unlink($reportPath); unlink($reportPath);
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); // @phpstan-ignore-next-line
if (is_array($codeCoverage)) {
$facade = Facade::fromSerializedData($codeCoverage);
/** @var Directory<File|Directory> $report */ /** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport(); $report = (fn (): Directory => $this->report)->call($facade);
} else {
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
}
$totalCoverage = $report->percentageOfExecutedLines();
foreach ($report->getIterator() as $file) { foreach ($report->getIterator() as $file) {
if (! $file instanceof File) { if (! $file instanceof File) {
+13
View File
@@ -86,4 +86,17 @@ final readonly class Exporter
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value)); return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
} }
/**
* Exports a value into a full single-line string without truncation.
*/
public function export(mixed $value): string
{
$map = [
'#\\\n\s*#' => '',
'# Object \(\.{3}\)#' => '',
];
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
}
} }
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class Git
{
private const float TIMEOUT = 5.0;
public function __construct(
private ?string $directory = null,
private float $timeout = self::TIMEOUT,
) {}
public function withTimeout(float $timeout): self
{
return new self($this->directory, $timeout);
}
/**
* @param array<int, string> $arguments
*/
public function raw(array $arguments): ?string
{
$result = $this->result($arguments);
return $result['exitCode'] === 0 ? $result['output'] : null;
}
/**
* @param array<int, string> $arguments
*/
public function output(array $arguments): ?string
{
$output = $this->raw($arguments);
if ($output === null) {
return null;
}
$output = trim($output);
return $output === '' ? null : $output;
}
/**
* @param array<int, string> $arguments
*/
public function succeeds(array $arguments): bool
{
return $this->result($arguments)['exitCode'] === 0;
}
/**
* @param array<int, string> $arguments
* @return array{exitCode: int, output: string}
*/
public function result(array $arguments, ?string $input = null): array
{
$process = new Process(['git', ...$arguments], $this->directory);
$process->setTimeout($this->timeout);
if ($input !== null) {
$process->setInput($input);
}
$process->run();
return [
'exitCode' => $process->getExitCode() ?? 1,
'output' => $process->getOutput(),
];
}
public function isRepository(): bool
{
return $this->succeeds(['rev-parse', '--git-dir']);
}
public function hasCommits(): bool
{
return $this->succeeds(['rev-parse', '--verify', '--quiet', 'HEAD']);
}
public function hasRemote(): bool
{
return $this->output(['remote']) !== null;
}
public function hasRef(string $ref): bool
{
return $this->output(['rev-parse', '--verify', '--quiet', $ref]) !== null;
}
public function show(string $sha, string $path): ?string
{
return $this->raw(['show', $sha.':'.$path]);
}
public function subdirectoryPrefix(): ?string
{
$prefix = $this->output(['rev-parse', '--show-prefix']);
if ($prefix === null) {
return null;
}
return rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ final class HigherOrderMessage
} }
if ($this->hasHigherOrderCallable()) { if ($this->hasHigherOrderCallable()) {
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments); return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
} }
try { try {

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