Compare commits

..

232 Commits

Author SHA1 Message Date
nuno maduro 5b2293f67a release: v4.7.8 2026-08-03 16:49:44 -04:00
nuno maduro 7d49828491 chore: adjusts snapshots 2026-08-03 16:49:35 -04:00
nuno maduro 3223d50607 fix dataset key 2026-08-03 16:47:34 -04:00
nuno maduro 7d187dd50a release: v4.7.7 2026-07-31 21:52:59 -04:00
nuno maduro 758778fe22 chore: removes --tia hidden code 2026-07-31 21:35:42 -04: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 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 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
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
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
nuno maduro e5ab3af05b ci: update dependabot config (add cooldown, single update entry) 2026-06-04 17:54:45 +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 92e76eb5ab ci: runs ci only against stable 2026-06-01 06:32:29 +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 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 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 2fc75cfcf0 chore: updates snapshots 2026-05-03 13:09:32 -03:00
nuno maduro 6cc48f63f8 chore: style 2026-05-03 13:06:24 -03:00
nuno maduro e0419d1328 release: v4.7.0 2026-05-03 12:46:24 -03:00
nuno maduro faa6988801 Merge pull request #1682 from pestphp/feat/tia
[4.x] TIA Engine
2026-05-03 16:27:58 +01:00
nuno maduro c12247fafd Revert "wip"
This reverts commit 1b168aba1c.
2026-05-03 11:45:39 -03:00
nuno maduro 29b4452443 wip 2026-05-03 11:33:30 -03:00
nuno maduro 1b168aba1c wip 2026-05-03 11:30:13 -03:00
nuno maduro 6aabd977cd wip 2026-05-03 11:01:52 -03:00
nuno maduro a882543c53 wip 2026-05-03 10:57:28 -03:00
nuno maduro c250b9da4f wip 2026-05-03 10:37:17 -03:00
nuno maduro 46bc3dc628 wip 2026-05-03 10:34:44 -03:00
nuno maduro d3ce498b8a wip 2026-05-03 10:31:47 -03:00
nuno maduro e1a4b98b71 wip 2026-05-03 10:16:10 -03:00
nuno maduro 9afbcd5c18 wuip 2026-05-03 09:54:02 -03:00
nuno maduro 75593b6454 wip 2026-05-03 13:37:27 +01:00
nuno maduro 89590d6120 wip 2026-05-03 13:35:01 +01:00
nuno maduro fb0978c9bf wip 2026-05-03 13:26:48 +01:00
nuno maduro a3796daa42 wip 2026-05-03 13:24:10 +01:00
nuno maduro e3004db666 wip 2026-05-03 12:43:38 +01:00
nuno maduro 99cc4e0146 wip 2026-05-02 19:33:09 +01:00
nuno maduro a47e6f8fef wip 2026-05-02 19:30:14 +01:00
nuno maduro 536d79f765 wip 2026-05-02 19:20:55 +01:00
nuno maduro 65c0fbc528 wip 2026-05-02 19:07:41 +01:00
nuno maduro 9e4cf4b665 wip 2026-05-02 18:58:42 +01:00
nuno maduro 7bea819978 wip 2026-05-02 18:47:26 +01:00
nuno maduro 4280233b40 wip 2026-05-02 18:37:24 +01:00
nuno maduro d6db3a8a20 wip 2026-05-02 18:32:05 +01:00
nuno maduro 51c8ce4df6 wip 2026-05-02 18:31:32 +01:00
nuno maduro 5b8393b925 wip 2026-05-02 18:25:41 +01:00
nuno maduro e4d9b61fdf wip 2026-05-02 18:25:27 +01:00
nuno maduro e2d940cd53 wip 2026-05-02 18:25:21 +01:00
nuno maduro 380ccd30b4 wip 2026-05-02 18:03:25 +01:00
nuno maduro 31c200716d wip 2026-05-02 18:03:14 +01:00
nuno maduro 6add4da543 wip 2026-05-02 18:02:20 +01:00
nuno maduro 8ddcd3e853 wip 2026-05-02 18:02:13 +01:00
nuno maduro e3e178fd94 wip 2026-05-02 17:59:21 +01:00
nuno maduro 7b1ec9f003 wip 2026-05-02 17:59:13 +01:00
nuno maduro 1e48c5d473 wip 2026-05-02 17:59:00 +01:00
nuno maduro d00ec95dd9 wip 2026-05-02 17:58:55 +01:00
nuno maduro 89f3d6cb39 wip 2026-05-02 17:45:54 +01:00
nuno maduro a07a2e512a wip 2026-05-02 17:39:15 +01:00
nuno maduro 57eecb2b3d wip 2026-05-02 17:38:12 +01:00
nuno maduro 9f804dc954 wip 2026-05-02 17:38:08 +01:00
nuno maduro 7cbad4c589 wip 2026-05-02 17:38:01 +01:00
nuno maduro 5cae93b059 wip 2026-05-02 17:37:56 +01:00
nuno maduro df829ad19d wip 2026-05-02 17:37:47 +01:00
nuno maduro 635460653c wip 2026-05-02 17:37:34 +01:00
nuno maduro 1aa80dc398 wip 2026-05-02 17:18:35 +01:00
nuno maduro 8a14056111 wip 2026-05-02 17:15:46 +01:00
nuno maduro f247dd8e7b wip 2026-05-02 17:11:49 +01:00
nuno maduro 1c7c9754fd wip 2026-05-02 17:07:08 +01:00
nuno maduro 5f37939fda wip 2026-05-02 17:02:11 +01:00
nuno maduro 28305fcb7a wip 2026-05-02 16:35:52 +01:00
nuno maduro 5242803694 wip 2026-05-02 15:54:00 +01:00
nuno maduro 925935a7e8 wip 2026-05-02 15:33:38 +01:00
nuno maduro 460401c379 wip 2026-05-02 15:26:58 +01:00
nuno maduro 348b439172 wip 2026-05-02 15:15:53 +01:00
nuno maduro a4e77766c5 wip 2026-05-02 15:07:51 +01:00
nuno maduro 4a8c2d7d78 wip 2026-05-02 15:03:44 +01:00
nuno maduro 7d51601120 wip 2026-05-02 14:15:37 +01:00
nuno maduro 631bbe318b wip 2026-05-02 13:43:32 +01:00
nuno maduro 9b7c15d5b6 wip 2026-05-02 12:03:35 +01:00
nuno maduro 872796bd9b wip 2026-05-02 12:00:47 +01:00
nuno maduro c38d32ae86 wip 2026-05-02 09:49:33 +01:00
nuno maduro 6407c4f78f wip 2026-05-02 01:58:39 +01:00
nuno maduro 6e1bf63f6a wip 2026-05-02 01:40:35 +01:00
nuno maduro 1d3e8bb5dd wip 2026-05-02 01:03:06 +01:00
nuno maduro 3cc9b169e3 wip 2026-05-02 00:52:57 +01:00
nuno maduro c4911d046b wip 2026-05-02 00:06:04 +01:00
nuno maduro d0295f6168 wip 2026-05-01 23:59:25 +01:00
nuno maduro 21efbc3107 wip 2026-05-01 22:55:38 +01:00
nuno maduro e59b99cd73 wip 2026-05-01 22:51:55 +01:00
nuno maduro bf48e20880 wip 2026-05-01 22:36:15 +01:00
nuno maduro 53db68e005 wip 2026-05-01 22:31:00 +01:00
nuno maduro 34f1e9a7f2 fix 2026-05-01 21:51:09 +01:00
nuno maduro 57fd5ce042 wip 2026-05-01 21:50:56 +01:00
nuno maduro 3bcabfb63b fix 2026-05-01 21:50:52 +01:00
nuno maduro aa3a7c303a wip 2026-05-01 21:32:59 +01:00
nuno maduro 5c08a135f7 wip 2026-05-01 21:30:44 +01:00
nuno maduro 6e0e030d71 wip 2026-05-01 21:22:33 +01:00
nuno maduro b2c07561e7 wip 2026-05-01 20:54:24 +01:00
nuno maduro 97600b6f0b wip 2026-05-01 20:53:40 +01:00
nuno maduro 8a51f15d65 wip 2026-05-01 20:45:51 +01:00
nuno maduro a349f53964 wip 2026-05-01 20:42:14 +01:00
nuno maduro a725e774c0 wip 2026-05-01 20:28:39 +01:00
nuno maduro bed5e5b54a wip 2026-05-01 20:02:46 +01:00
nuno maduro 45b1d4ce20 wip 2026-05-01 19:50:54 +01:00
nuno maduro d106b70766 wip 2026-05-01 17:24:22 +01:00
nuno maduro 6ac6c1518e wip 2026-05-01 17:17:33 +01:00
nuno maduro fda515a17f wip 2026-05-01 16:42:01 +01:00
nuno maduro 0a97d3a288 asd 2026-05-01 15:33:06 +01:00
nuno maduro 3802fa80e6 asd 2026-05-01 15:19:19 +01:00
nuno maduro 5c3cbc14d2 wip 2026-05-01 15:07:10 +01:00
nuno maduro 6b9c768172 wip 2026-05-01 14:39:23 +01:00
nuno maduro 4a2fc179ae asd 2026-05-01 13:54:25 +01:00
nuno maduro b5bb2139dc wqdqwd 2026-05-01 12:57:12 +01:00
nuno maduro 07416a3c61 wip 2026-05-01 03:30:28 +01:00
nuno maduro 30b94e3034 qdw 2026-05-01 02:10:08 +01:00
nuno maduro be34eecb2f wip 2026-05-01 01:55:18 +01:00
nuno maduro 5d9f95f8d4 qwdqwd 2026-05-01 01:44:08 +01:00
nuno maduro 48b70a03d5 wip 2026-05-01 01:32:48 +01:00
nuno maduro 4b8642b972 wip 2026-05-01 00:48:31 +01:00
nuno maduro 8711d51eac fix 2026-05-01 00:19:44 +01:00
nuno maduro 58dfb6da64 wip 2026-04-30 22:12:53 +01:00
nuno maduro d7735d1faa wip 2026-04-30 22:00:56 +01:00
nuno maduro 6b59166f3c wip 2026-04-30 21:08:00 +01:00
nuno maduro 3a26028d17 wip 2026-04-30 20:58:06 +01:00
nuno maduro 3c91bf4ad2 wip 2026-04-30 20:51:57 +01:00
nuno maduro 6a434be0f6 wip 2026-04-30 20:45:36 +01:00
nuno maduro f355b99bbf wip 2026-04-29 22:59:56 +01:00
nuno maduro 95a00341e9 wip for now 2026-04-29 02:22:37 +01:00
nuno maduro 466259646d wip 2026-04-28 22:12:42 +01:00
nuno maduro 00f8d56083 wip 2026-04-28 21:41:20 +01:00
nuno maduro ca2dca592d wup 2026-04-28 21:34:40 +01:00
nuno maduro 405d8d4406 wip 2026-04-28 21:28:46 +01:00
nuno maduro b944ee5841 wip 2026-04-27 19:15:42 +01:00
nuno maduro f4e22dcafe wip 2026-04-27 18:57:41 +01:00
nuno maduro 339c1e8cac wip 2026-04-27 18:14:10 +01:00
nuno maduro d4c7362132 wip 2026-04-27 16:56:27 +01:00
nuno maduro 81bfdbf8fe wip 2026-04-27 13:16:05 +01:00
nuno maduro f45cbf43c5 wip 2026-04-27 13:11:48 +01:00
nuno maduro b9088d23fb wip 2026-04-27 13:03:07 +01:00
nuno maduro 7250185423 wip 2026-04-27 12:22:05 +01:00
nuno maduro e457eb0e9c wip 2026-04-27 11:15:59 +01:00
nuno maduro 48357c6f30 wip 2026-04-27 10:30:08 +01:00
nuno maduro b46f051550 wip 2026-04-23 17:32:27 -07:00
nuno maduro 3d3c5d41ac wip 2026-04-23 12:29:24 -07:00
nuno maduro caabebf2a1 wip 2026-04-23 10:56:17 -07:00
nuno maduro 470a5833d4 wip 2026-04-23 10:30:44 -07:00
nuno maduro c1feefbb9e wip 2026-04-23 09:44:12 -07:00
nuno maduro e876dba8ba wip 2026-04-23 09:29:56 -07:00
nuno maduro d9c18f9c02 wip 2026-04-22 09:03:10 -07:00
nuno maduro 660b57b365 wip 2026-04-22 08:42:32 -07:00
nuno maduro 68527c996f wip 2026-04-22 08:25:38 -07:00
nuno maduro c6a42a2b28 wip 2026-04-22 08:07:52 -07:00
nuno maduro fcf5c27914 chore: adds YouTube channel badge
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:56:04 -07:00
nuno maduro 856a370032 style 2026-04-21 09:44:26 -07:00
nuno maduro e24882c486 wip 2026-04-21 09:41:19 -07:00
nuno maduro 51fc380789 wip 2026-04-21 09:40:01 -07:00
nuno maduro f6609f4039 wip 2026-04-21 08:36:41 -07:00
nuno maduro 2941f9821f wip 2026-04-21 08:15:24 -07:00
nuno maduro ed399af43e wip 2026-04-21 07:41:50 -07:00
nuno maduro 0d66dc4322 chore: removes https 2026-04-21 07:26:19 -07:00
nuno maduro 7e4280bf83 chore: improves feedback 2026-04-21 07:13:08 -07:00
nuno maduro a5915b16ab wip 2026-04-20 20:58:38 -07:00
nuno maduro 1476b529a1 wip 2026-04-20 19:56:23 -07:00
nuno maduro 2892341c28 wip 2026-04-20 14:28:18 -07:00
nuno maduro 59e781e77b wip 2026-04-20 13:48:05 -07:00
nuno maduro 55a3394f8c wip 2026-04-20 13:31:43 -07:00
nuno maduro 0d99c33c4e wip 2026-04-20 13:16:59 -07:00
nuno maduro adc5aae6f8 wip 2026-04-20 13:00:41 -07:00
nuno maduro 980667e845 wip 2026-04-20 12:25:39 -07:00
nuno maduro 8c849c5f40 fix: missing warning 2026-04-20 12:18:32 -07:00
nuno maduro 47f1fc2d94 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro 9c8033d60c feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro 42d1092a9e feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro c7e32f5d33 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro d379128cc4 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro f09d6f2064 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro 494cc6e2a4 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro f52a455773 fix 2026-04-20 10:09:07 -07:00
nuno maduro 184f5d2742 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro 1d81069a2a feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
nuno maduro 4b9bb77b54 feat(tia): adds poc 2026-04-20 10:09:07 -07:00
nuno maduro c440031e28 feat(tia): adds poc 2026-04-20 10:09:07 -07:00
nuno maduro bff44562a9 release: v4.6.3 2026-04-18 06:51:25 -07:00
nuno maduro 9ebb990f96 chore: bumps phpunit 2026-04-18 06:51:17 -07:00
nuno maduro cabff738f7 release: v4.6.2 2026-04-17 19:32:23 -07:00
nuno maduro 0746173a32 chore: bumps phpunit 2026-04-17 19:32:18 -07:00
nuno maduro 87db0b4847 release: v4.6.1 2026-04-15 09:03:09 -07:00
nuno maduro 6ba373a772 chore: bumps phpunit 2026-04-15 08:49:34 -07:00
nuno maduro 945d476409 fix: allow to update individual screenshots 2026-04-15 08:34:06 -07:00
nuno maduro a8cf0fe2cb chore: improves CI 2026-04-15 08:20:50 -07:00
nuno maduro 2ae072bb95 feat: makes boot time much faster 2026-04-15 07:47:38 -07:00
nuno maduro 59d066950c chore: missing header 2026-04-15 07:47:22 -07:00
nuno maduro 0dd1aa72ef fix: updating snapshots in --parallel 2026-04-15 07:22:10 -07:00
nuno maduro 4e03cd3edb release: v4.6.0 2026-04-14 10:23:26 -07:00
nuno maduro eeab24e2bb Merge pull request #1671 from pestphp/feat/time-based-sharding
[4.x] Time based sharding
2026-04-14 18:18:09 +01:00
nuno maduro 9b64d5425a removes time balanced 2026-04-14 10:12:57 -07:00
nuno maduro 0acab1cbb4 wip 2026-04-14 09:53:57 -07:00
nuno maduro e616eab9fb wip 2026-04-14 09:36:38 -07:00
nuno maduro 7cbb1fcdb2 wip 2026-04-14 09:29:41 -07:00
nuno maduro cb5f6e1bd2 chore: style 2026-04-14 09:17:18 -07:00
nuno maduro 985dadd934 update 2026-04-14 09:16:32 -07:00
nuno maduro 10aee6045c feat(time-based-sharding): updates exception name 2026-04-14 09:08:52 -07:00
nuno maduro 4ac14b2528 feat(time-based-sharding): updates plugin 2026-04-14 08:34:41 -07:00
49 changed files with 1325 additions and 120 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:
- "*"
+10 -5
View File
@@ -11,6 +11,9 @@ concurrency:
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
static:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
@@ -21,14 +24,14 @@ jobs:
strategy:
fail-fast: true
matrix:
dependency-version: [prefer-lowest, prefer-stable]
dependency-version: [prefer-stable]
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: 8.3
tools: composer:v2
@@ -41,15 +44,17 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
static-php-8.3-${{ matrix.dependency-version }}-composer-
static-php-8.3-composer-
- name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 4.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check
+9 -4
View File
@@ -11,6 +11,9 @@ concurrency:
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
tests:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
@@ -32,10 +35,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
@@ -48,10 +51,10 @@ jobs:
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json') }}
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
${{ matrix.os }}-php-${{ matrix.php }}-composer-
@@ -63,6 +66,8 @@ jobs:
- name: Install PHP dependencies
shell: bash
env:
COMPOSER_ROOT_VERSION: 4.x-dev
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests
+2 -1
View File
@@ -1,11 +1,12 @@
<p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (4.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
</p>
</p>
+1
View File
@@ -142,6 +142,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2);
$input = new ArgvInput;
$testSuite = TestSuite::getInstance(
+7 -6
View File
@@ -19,18 +19,19 @@
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"nunomaduro/collision": "^8.9.3",
"composer/xdebug-handler": "^3.0.5",
"nunomaduro/collision": "^8.9.5",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.16",
"symfony/process": "^7.4.8|^8.0.8"
"phpunit/phpunit": "^12.5.33",
"symfony/process": "^7.4.13|^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.16",
"phpunit/phpunit": ">12.5.33",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@@ -58,11 +59,11 @@
]
},
"require-dev": {
"mrpunyapal/peststan": "^0.2.5",
"mrpunyapal/peststan": "^0.2.12",
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
"psy/psysh": "^0.12.22"
"psy/psysh": "^0.12.24"
},
"minimum-stability": "dev",
"prefer-stable": true,
@@ -1,6 +1,39 @@
<?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
+388
View File
@@ -0,0 +1,388 @@
<?php
/*
* BSD 3-Clause License
*
* Copyright (c) 2001-2023, Sebastian Bergmann
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Runner;
use PHPUnit\Framework\DataProviderTestSuite;
use PHPUnit\Framework\Reorderable;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\ResultCache\NullResultCache;
use PHPUnit\Runner\ResultCache\ResultCache;
use PHPUnit\Runner\ResultCache\ResultCacheId;
use function array_diff;
use function array_merge;
use function array_reverse;
use function array_splice;
use function assert;
use function count;
use function in_array;
use function max;
use function shuffle;
use function usort;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class TestSuiteSorter
{
public const int ORDER_DEFAULT = 0;
public const int ORDER_RANDOMIZED = 1;
public const int ORDER_REVERSED = 2;
public const int ORDER_DEFECTS_FIRST = 3;
public const int ORDER_DURATION = 4;
public const int ORDER_SIZE = 5;
/**
* @var non-empty-array<non-empty-string, positive-int>
*/
private const array SIZE_SORT_WEIGHT = [
'small' => 1,
'medium' => 2,
'large' => 3,
'unknown' => 4,
];
/**
* @var array<string, int> Associative array of (string => DEFECT_SORT_WEIGHT) elements
*/
private array $defectSortOrder = [];
private readonly ResultCache $cache;
public function __construct(?ResultCache $cache = null)
{
$this->cache = $cache ?? new NullResultCache;
}
/**
* @throws Exception
*/
public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
$allowedOrders = [
self::ORDER_DEFAULT,
self::ORDER_REVERSED,
self::ORDER_RANDOMIZED,
self::ORDER_DURATION,
self::ORDER_SIZE,
];
if (! in_array($order, $allowedOrders, true)) {
// @codeCoverageIgnoreStart
throw new InvalidOrderException;
// @codeCoverageIgnoreEnd
}
$allowedOrderDefects = [
self::ORDER_DEFAULT,
self::ORDER_DEFECTS_FIRST,
];
if (! in_array($orderDefects, $allowedOrderDefects, true)) {
// @codeCoverageIgnoreStart
throw new InvalidOrderException;
// @codeCoverageIgnoreEnd
}
if ($suite instanceof TestSuite) {
foreach ($suite as $_suite) {
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
}
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
$this->addSuiteToDefectSortOrder($suite);
}
$this->sort($suite, $order, $resolveDependencies, $orderDefects);
}
}
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
{
if ($suite->tests() === []) {
return;
}
if ($order === self::ORDER_REVERSED) {
$suite->setTests($this->reverse($suite->tests()));
} elseif ($order === self::ORDER_RANDOMIZED) {
$suite->setTests($this->randomize($suite->tests()));
} elseif ($order === self::ORDER_DURATION) {
$suite->setTests($this->sortByDuration($suite->tests()));
} elseif ($order === self::ORDER_SIZE) {
$suite->setTests($this->sortBySize($suite->tests()));
}
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
$suite->setTests($this->sortDefectsFirst($suite->tests()));
}
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
$tests = $suite->tests();
/** @noinspection PhpParamsInspection */
/** @phpstan-ignore argument.type */
$suite->setTests($this->resolveDependencies($tests));
}
}
private function addSuiteToDefectSortOrder(TestSuite $suite): void
{
$max = 0;
foreach ($suite->tests() as $test) {
assert($test instanceof Reorderable);
$sortId = $test->sortId();
if (! isset($this->defectSortOrder[$sortId])) {
$this->defectSortOrder[$sortId] = $this->cache->status(ResultCacheId::fromReorderable($test))->asInt();
$max = max($max, $this->defectSortOrder[$sortId]);
}
}
$this->defectSortOrder[$suite->sortId()] = $max;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function reverse(array $tests): array
{
return array_reverse($tests);
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function randomize(array $tests): array
{
shuffle($tests);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortDefectsFirst(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortByDuration(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpDuration($left, $right),
);
return $tests;
}
/**
* @param list<Test> $tests
* @return list<Test>
*/
private function sortBySize(array $tests): array
{
usort(
$tests,
fn (Test $left, Test $right) => $this->cmpSize($left, $right),
);
return $tests;
}
/**
* Comparator callback function to sort tests for "reach failure as fast as possible".
*
* 1. sort tests by defect weight defined in self::DEFECT_SORT_WEIGHT
* 2. when tests are equally defective, sort the fastest to the front
* 3. do not reorder successful tests
*/
private function cmpDefectPriorityAndTime(Test $a, Test $b): int
{
assert($a instanceof Reorderable);
assert($b instanceof Reorderable);
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
if ($priorityA !== $priorityB) {
// Sort defect weight descending
return $priorityB <=> $priorityA;
}
if ($priorityA > 0 || $priorityB > 0) {
return $this->cmpDuration($a, $b);
}
// do not change execution order
return 0;
}
/**
* Compares test duration for sorting tests by duration ascending.
*/
private function cmpDuration(Test $a, Test $b): int
{
if (! ($a instanceof Reorderable && $b instanceof Reorderable)) {
return 0;
}
return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b));
}
/**
* Compares test size for sorting tests small->medium->large->unknown.
*/
private function cmpSize(Test $a, Test $b): int
{
$sizeA = ($a instanceof TestCase || $a instanceof DataProviderTestSuite)
? $a->size()->asString()
: 'unknown';
$sizeB = ($b instanceof TestCase || $b instanceof DataProviderTestSuite)
? $b->size()->asString()
: 'unknown';
return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB];
}
/**
* Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible.
* The algorithm will leave the tests in original running order when it can.
* For more details see the documentation for test dependencies.
*
* Short description of algorithm:
* 1. Pick the next Test from remaining tests to be checked for dependencies.
* 2. If the test has no dependencies: mark done, start again from the top
* 3. If the test has dependencies but none left to do: mark done, start again from the top
* 4. When we reach the end add any leftover tests to the end. These will be marked 'skipped' during execution.
*
* @param array<TestCase> $tests
* @return array<TestCase>
*/
private function resolveDependencies(array $tests): array
{
// Pest: Fast-path. If no test in this suite declares dependencies, the
// original O(N^2) algorithm is wasted work — it would splice each test
// one-by-one back into the same order. The check deliberately walks
// TestCase instances directly instead of calling TestSuite::requires(),
// because the latter lazily builds TestSuite::provides() via
// ExecutionOrderDependency::mergeUnique, which is O(N^2) in the total
// number of tests. With thousands of tests that single call alone can
// burn several seconds before the sort even begins. Reading the
// cached TestCase::$dependencies property stays O(N) and costs nothing
// when no test uses `->depends()` / PHPUnit `@depends`.
if (! $this->anyTestHasDependencies($tests)) {
return $tests;
}
$newTestOrder = [];
$i = 0;
$provided = [];
do {
if (array_diff($tests[$i]->requires(), $provided) === []) {
$provided = array_merge($provided, $tests[$i]->provides());
$newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1));
$i = 0;
} else {
$i++;
}
} while ($tests !== [] && ($i < count($tests)));
return array_merge($newTestOrder, $tests);
}
/**
* Cheaply determines whether any test in the tree declares @depends.
*
* Walks `TestSuite` containers recursively and inspects each `TestCase`
* directly so it never triggers `TestSuite::provides()`, which is O(N^2)
* in the total number of aggregated tests.
*
* @param iterable<Test> $tests
*/
private function anyTestHasDependencies(iterable $tests): bool
{
foreach ($tests as $test) {
if ($test instanceof TestSuite) {
if ($this->anyTestHasDependencies($test->tests())) {
return true;
}
continue;
}
if ($test instanceof TestCase && $test->requires() !== []) {
return true;
}
}
return false;
}
}
+8
View File
@@ -4,7 +4,10 @@ declare(strict_types=1);
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
@@ -18,6 +21,11 @@ return RectorConfig::configure()
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class,
RemoveDuplicatedReturnSelfDocblockRector::class,
RemoveUselessUnionReturnDocblockRector::class,
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
],
])
->withPreparedSets(
deadCode: true,
+2
View File
@@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
};
?>
-4
View File
@@ -176,9 +176,5 @@ final class Laravel extends AbstractPreset
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute')
->toHaveMethod('resolve');
$this->expectations[] = expect('App\Rules')
->classes()
->toImplement('Illuminate\Contracts\Validation\ValidationRule');
}
}
+1
View File
@@ -21,6 +21,7 @@ final class BootOverrides implements Bootstrapper
'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php',
'Runner/TestSuiteSorter.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'TextUI/TestSuiteFilterProcessor.php',
+5
View File
@@ -368,6 +368,11 @@ trait Testable
if ($hasOutputExpectation) {
ob_clean();
Closure::bind(function (): void {
$this->outputExpectedString = null;
$this->outputExpectedRegex = null;
}, $this, TestCase::class)();
}
$this->setUp();
+7 -4
View File
@@ -59,12 +59,15 @@ final class Project
/**
* Sets the test project to GitLab.
*/
public function gitlab(string $project): self
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
{
$this->issues = "https://gitlab.com/{$project}/issues/%s";
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
// Simple way to ensure only the host is used
$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;
}
+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);
}
}
+11 -5
View File
@@ -112,7 +112,7 @@ final class Expectation
if (function_exists('dump')) {
dump($this->value, ...$arguments);
} else {
var_dump($this->value);
var_dump($this->value, ...$arguments);
}
return $this;
@@ -120,16 +120,22 @@ final class Expectation
/**
* Dump the expectation value and end the script.
*
* @return never
*/
public function dd(mixed ...$arguments): void
public function dd(mixed ...$arguments): never
{
if (function_exists('dd')) {
dd($this->value, ...$arguments);
}
var_dump($this->value);
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
ob_start();
var_dump($this->value, ...$arguments);
$output = ob_get_clean();
throw new ExpectationFailedException($output);
}
var_dump($this->value, ...$arguments);
exit(1);
}
+6 -2
View File
@@ -94,7 +94,8 @@ final class TestCaseFactory
$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;
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
@@ -149,6 +150,8 @@ final class TestCaseFactory
$attributesCode = Attributes::code($this->attributes);
$filenameLiteral = var_export($realpath, true);
$methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
$methods
@@ -158,6 +161,7 @@ final class TestCaseFactory
$classCode = <<<PHP
namespace $namespace;
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite;
@@ -166,7 +170,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
private static \$__filename = '$filename';
public static \$__filename = $filenameLiteral;
$methodsCode
}
+8
View File
@@ -235,6 +235,10 @@ final class TestCaseMethodFactory
$attributesCode
public function $methodName(...\$arguments)
{
if (count(\$arguments) === 1 && \$arguments[array_key_first(\$arguments)] instanceof __PestDatasetProviderError) {
throw \$arguments[array_key_first(\$arguments)]->getPrevious() ?? \$arguments[array_key_first(\$arguments)];
}
return \$this->__runTest(
\$this->__test,
...\$arguments,
@@ -261,7 +265,11 @@ final class TestCaseMethodFactory
public static function $dataProviderName()
{
try {
return __PestDatasets::get(self::\$__filename, "$methodName");
} catch (\Throwable \$throwable) {
return [[new __PestDatasetProviderError(\$throwable)]];
}
}
EOF;
+19 -9
View File
@@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/**
* @internal
*/
final readonly class Kernel
final class Kernel
{
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/**
* The Kernel bootstrappers.
*
@@ -46,12 +51,7 @@ final readonly class Kernel
/**
* Creates a new Kernel instance.
*/
public function __construct(
private Application $application,
private OutputInterface $output,
) {
//
}
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
/**
* Boots the Kernel.
@@ -112,9 +112,13 @@ final readonly class Kernel
$configuration = Registry::get();
$result = Facade::result();
return CallsAddsOutput::execute(
$result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result),
);
$this->terminate();
return $result;
}
/**
@@ -122,6 +126,12 @@ final readonly class Kernel
*/
public function terminate(): void
{
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump);
@@ -139,7 +149,7 @@ final readonly class Kernel
$this->terminate();
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;
}
+10 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest;
use Laravel\Pao\Execution;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
@@ -28,6 +29,10 @@ final class KernelDump
*/
public function enable(): void
{
if (class_exists(Execution::class) && Execution::running()) {
return;
}
ob_start(function (string $message): string {
$this->buffer .= $message;
@@ -68,6 +73,10 @@ final class KernelDump
$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)) {
$type = 'ERROR';
$this->buffer = str_replace(
@@ -107,7 +116,6 @@ final class KernelDump
*/
private function isInternalError(string $output): bool
{
return str_contains($output, 'An error occurred inside PHPUnit.')
|| str_contains($output, 'Fatal error');
return str_contains($output, 'An error occurred inside PHPUnit.');
}
}
+6 -2
View File
@@ -12,7 +12,9 @@ use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
@@ -255,9 +257,11 @@ final readonly class Converter
$numberOfNotPassedTests = count(
array_unique(
array_map(
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) {
|| $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName();
}
+24 -9
View File
@@ -14,6 +14,7 @@ use InvalidArgumentException;
use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue;
use Pest\Matchers\Any;
use Pest\Plugins\Snapshot;
use Pest\Support\Arr;
use Pest\Support\Exporter;
use Pest\Support\NullClosure;
@@ -851,18 +852,31 @@ final class Expectation
default => InvalidExpectationValue::expected('array|object|string'),
};
if ($snapshots->has()) {
[$filename, $content] = $snapshots->get();
Assert::assertSame(
strtr($content, ["\r\n" => "\n", "\r" => "\n"]),
strtr($string, ["\r\n" => "\n", "\r" => "\n"]),
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
} else {
if (! $snapshots->has()) {
$filename = $snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
} else {
[$filename, $content] = $snapshots->get();
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
$snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
} else {
if (Snapshot::$updateSnapshots) {
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
}
Assert::assertSame(
$normalizedContent,
$normalizedString,
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
}
}
return $this;
@@ -940,6 +954,7 @@ final class Expectation
} catch (Throwable $e) {
if ($exception instanceof Throwable) {
// @phpstan-ignore-next-line
expect($e)
->toBeInstanceOf($exception::class, $message)
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.4.4';
return '4.7.8';
}
function testDirectory(string $file = ''): string
+4
View File
@@ -123,6 +123,10 @@ final readonly class Help implements HandlesArguments
'arg' => '--update-snapshots',
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
],
[
'arg' => '--update-shards',
'desc' => 'Update shards.json with test timing data for time-balanced sharding',
],
], ...$content['Execution']];
$content['Selection'] = [[
@@ -146,7 +146,6 @@ final class WrapperRunner implements RunnerInterface
public function run(): int
{
$directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory);
TestResultFacade::init();
EventFacade::instance()->seal();
+356 -3
View File
@@ -6,7 +6,13 @@ namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption;
use Pest\Subscribers\EnsureShardTimingFinished;
use Pest\Subscribers\EnsureShardTimingsAreCollected;
use Pest\Subscribers\EnsureShardTimingStarted;
use Pest\TestSuite;
use PHPUnit\Event;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -15,7 +21,7 @@ use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Shard implements AddsOutput, HandlesArguments
final class Shard implements AddsOutput, HandlesArguments, Terminable
{
use Concerns\HandleArguments;
@@ -33,6 +39,40 @@ final class Shard implements AddsOutput, HandlesArguments
*/
private static ?array $shard = null;
/**
* Whether to update the shards.json file.
*/
private static bool $updateShards = false;
/**
* Whether time-balanced sharding was used.
*/
private static bool $timeBalanced = false;
/**
* Whether the shards.json file is outdated.
*/
private static bool $shardsOutdated = false;
/**
* Whether the test suite passed.
*/
private static bool $passed = false;
/**
* Collected timings from workers or subscribers.
*
* @var array<string, float>|null
*/
private static ?array $collectedTimings = null;
/**
* The canonical list of test classes from --list-tests.
*
* @var list<string>|null
*/
private static ?array $knownTests = null;
/**
* Creates a new Plugin instance.
*/
@@ -47,6 +87,19 @@ final class Shard implements AddsOutput, HandlesArguments
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgument('--update-shards', $arguments)) {
return $this->handleUpdateShards($arguments);
}
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
self::$updateShards = true;
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
return $arguments;
}
if (! $this->hasArgument('--shard', $arguments)) {
return $arguments;
}
@@ -63,7 +116,24 @@ final class Shard implements AddsOutput, HandlesArguments
/** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments);
$timings = $this->loadShardsFile();
if ($timings !== null) {
$knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
$newTests = array_values(array_diff($tests, $knownTests));
$partitions = $this->partitionByTime($knownTests, $timings, $total);
foreach ($newTests as $i => $test) {
$partitions[$i % $total][] = $test;
}
$testsToRun = $partitions[$index - 1] ?? [];
self::$timeBalanced = true;
self::$shardsOutdated = $newTests !== [];
} else {
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
}
self::$shard = [
'index' => $index,
@@ -72,9 +142,43 @@ final class Shard implements AddsOutput, HandlesArguments
'testsCount' => count($tests),
];
if ($testsToRun === []) {
return $arguments;
}
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
}
/**
* Handles the --update-shards argument.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function handleUpdateShards(array $arguments): array
{
if ($this->hasArgument('--shard', $arguments)) {
throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
}
$arguments = $this->popArgument('--update-shards', $arguments);
self::$updateShards = true;
/** @phpstan-ignore-next-line */
self::$knownTests = $this->allTests($arguments);
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
Parallel::setGlobal('UPDATE_SHARDS', true);
Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true));
} else {
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
}
return $arguments;
}
/**
* Returns all tests that the test suite would run.
*
@@ -87,7 +191,7 @@ final class Shard implements AddsOutput, HandlesArguments
'php',
...$this->removeParallelArguments($arguments),
'--list-tests',
]))->mustRun()->getOutput();
]))->setTimeout(120)->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
@@ -116,6 +220,22 @@ final class Shard implements AddsOutput, HandlesArguments
*/
public function addOutput(int $exitCode): int
{
self::$passed = $exitCode === 0;
if (self::$updateShards && self::$passed && ! Parallel::isWorker()) {
self::$collectedTimings = $this->collectTimings();
$count = self::$knownTests !== null
? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
: count(self::$collectedTimings);
$this->output->writeln(sprintf(
' <fg=gray>Shards:</> <fg=default>shards.json updated with timings for %d test class%s.</>',
$count,
$count === 1 ? '' : 'es',
));
}
if (self::$shard === null) {
return $exitCode;
}
@@ -128,17 +248,250 @@ final class Shard implements AddsOutput, HandlesArguments
] = self::$shard;
$this->output->writeln(sprintf(
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d%s.',
$index,
$total,
$testsRan,
$testsRan === 1 ? '' : 's',
$testsCount,
self::$timeBalanced ? ' <fg=gray>(time-balanced)</>' : '',
));
if (self::$shardsOutdated) {
$this->output->writeln(' <fg=yellow;options=bold>WARN</> <fg=default>The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.</>');
}
return $exitCode;
}
/**
* Terminates the plugin.
*/
public function terminate(): void
{
if (! self::$updateShards) {
return;
}
if (Parallel::isWorker()) {
$this->writeWorkerTimings();
return;
}
if (! self::$passed) {
return;
}
$timings = self::$collectedTimings ?? $this->collectTimings();
if ($timings === []) {
return;
}
$this->writeTimings($timings);
}
/**
* Collects timings from subscribers or worker temp files.
*
* @return array<string, float>
*/
private function collectTimings(): array
{
$runId = Parallel::getGlobal('SHARD_RUN_ID');
if (is_string($runId)) {
return $this->readWorkerTimings($runId);
}
return EnsureShardTimingsAreCollected::timings();
}
/**
* Writes the current worker's timing data to a temp file.
*/
private function writeWorkerTimings(): void
{
$timings = EnsureShardTimingsAreCollected::timings();
if ($timings === []) {
return;
}
$runId = Parallel::getGlobal('SHARD_RUN_ID');
if (! is_string($runId)) {
return;
}
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';
file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
}
/**
* Reads and merges timing data from all worker temp files.
*
* @return array<string, float>
*/
private function readWorkerTimings(string $runId): array
{
$pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
$files = glob($pattern);
if ($files === false || $files === []) {
return [];
}
$merged = [];
foreach ($files as $file) {
$contents = file_get_contents($file);
if ($contents === false) {
continue;
}
$timings = json_decode($contents, true);
if (is_array($timings)) {
$merged = array_merge($merged, $timings);
}
unlink($file);
}
return $merged;
}
/**
* Returns the path to shards.json.
*/
private function shardsPath(): string
{
$testSuite = TestSuite::getInstance();
return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
}
/**
* Loads the timings from shards.json.
*
* @return array<string, float>|null
*/
private function loadShardsFile(): ?array
{
$path = $this->shardsPath();
if (! file_exists($path)) {
return null;
}
$contents = file_get_contents($path);
if ($contents === false) {
throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
}
$data = json_decode($contents, true);
if (! is_array($data) || ! isset($data['timings']) || ! is_array($data['timings'])) {
throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
}
return $data['timings'];
}
/**
* Partitions tests across shards using the LPT (Longest Processing Time) algorithm.
*
* @param list<string> $tests
* @param array<string, float> $timings
* @return list<list<string>>
*/
private function partitionByTime(array $tests, array $timings, int $total): array
{
$knownTimings = array_filter(
array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
fn (?float $t): bool => $t !== null,
);
$median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;
$testsWithTimings = array_map(
fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
$tests,
);
usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);
/** @var list<list<string>> */
$bins = array_fill(0, $total, []);
/** @var non-empty-list<float> */
$binTimes = array_fill(0, $total, 0.0);
foreach ($testsWithTimings as $item) {
$minIndex = array_search(min($binTimes), $binTimes, strict: true);
assert(is_int($minIndex));
$bins[$minIndex][] = $item['test'];
$binTimes[$minIndex] += $item['time'];
}
return $bins;
}
/**
* Calculates the median of an array of floats.
*
* @param list<float> $values
*/
private function median(array $values): float
{
sort($values);
$count = count($values);
$middle = (int) floor($count / 2);
if ($count % 2 === 0) {
return ($values[$middle - 1] + $values[$middle]) / 2;
}
return $values[$middle];
}
/**
* Writes the timings to shards.json.
*
* @param array<string, float> $timings
*/
private function writeTimings(array $timings): void
{
$path = $this->shardsPath();
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (self::$knownTests !== null) {
$knownSet = array_flip(self::$knownTests);
$timings = array_intersect_key($timings, $knownSet);
}
ksort($timings);
$canonical = self::$knownTests ?? array_keys($timings);
sort($canonical);
file_put_contents($path, json_encode([
'timings' => $timings,
'checksum' => md5(implode("\n", $canonical)),
'updated_at' => date('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
}
/**
* Returns the shard information.
*
+98 -4
View File
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Pest\TestSuite;
/**
@@ -15,21 +14,116 @@ final class Snapshot implements HandlesArguments
{
use Concerns\HandleArguments;
/**
* Whether snapshots should be updated on this run.
*/
public static bool $updateSnapshots = false;
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SNAPSHOTS') === true) {
self::$updateSnapshots = true;
return $arguments;
}
if (! $this->hasArgument('--update-snapshots', $arguments)) {
return $arguments;
}
if ($this->hasArgument('--parallel', $arguments)) {
throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.');
self::$updateSnapshots = true;
if ($this->isFullRun($arguments)) {
TestSuite::getInstance()->snapshots->flush();
}
TestSuite::getInstance()->snapshots->flush();
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
Parallel::setGlobal('UPDATE_SNAPSHOTS', true);
}
return $this->popArgument('--update-snapshots', $arguments);
}
/**
* Options that take a value as the next argument (rather than via "=value").
*
* @var list<string>
*/
private const array FLAGS_WITH_VALUES = [
'--filter',
'--group',
'--exclude-group',
'--test-suffix',
'--covers',
'--uses',
'--cache-directory',
'--cache-result-file',
'--configuration',
'--colors',
'--test-directory',
'--bootstrap',
'--order-by',
'--random-order-seed',
'--log-junit',
'--log-teamcity',
'--log-events-text',
'--log-events-verbose-text',
'--coverage-clover',
'--coverage-cobertura',
'--coverage-crap4j',
'--coverage-html',
'--coverage-php',
'--coverage-text',
'--coverage-xml',
'--assignee',
'--issue',
'--ticket',
'--pr',
'--pull-request',
'--retry',
'--shard',
'--repeat',
];
/**
* Determines whether the command targets the entire suite (no filter, no path).
*
* @param array<int, string> $arguments
*/
private function isFullRun(array $arguments): bool
{
if ($this->hasArgument('--filter', $arguments)) {
return false;
}
$tokens = array_slice($arguments, 1);
$skipNext = false;
foreach ($tokens as $arg) {
if ($skipNext) {
$skipNext = false;
continue;
}
if ($arg === '') {
continue;
}
if ($arg[0] === '-') {
if (in_array($arg, self::FLAGS_WITH_VALUES, true)) {
$skipNext = true;
}
continue;
}
return false;
}
return true;
}
}
+4 -2
View File
@@ -59,8 +59,10 @@ final class SnapshotRepository
{
$snapshotFilename = $this->getSnapshotFilename();
if (! file_exists(dirname($snapshotFilename))) {
mkdir(dirname($snapshotFilename), 0755, true);
$directory = dirname($snapshotFilename);
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
}
file_put_contents($snapshotFilename, $snapshot);
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\FinishedSubscriber;
/**
* @internal
*/
final class EnsureShardTimingFinished implements FinishedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Finished $event): void
{
EnsureShardTimingsAreCollected::finished($event);
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\TestSuite\StartedSubscriber;
/**
* @internal
*/
final class EnsureShardTimingStarted implements StartedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Started $event): void
{
EnsureShardTimingsAreCollected::started($event);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\Started;
/**
* @internal
*/
final class EnsureShardTimingsAreCollected
{
/**
* The start times for each test class.
*
* @var array<string, HRTime>
*/
private static array $startTimes = [];
/**
* The collected timings for each test class.
*
* @var array<string, float>
*/
private static array $timings = [];
/**
* Records the start time for a test suite.
*/
public static function started(Started $event): void
{
if (! $event->testSuite()->isForTestClass()) {
return;
}
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
if (is_string($name)) {
self::$startTimes[$name] = $event->telemetryInfo()->time();
}
}
/**
* Records the duration for a test suite.
*/
public static function finished(Finished $event): void
{
if (! $event->testSuite()->isForTestClass()) {
return;
}
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
if (! is_string($name) || ! isset(self::$startTimes[$name])) {
return;
}
$duration = $event->telemetryInfo()->time()->duration(self::$startTimes[$name]);
self::$timings[$name] = round($duration->asFloat(), 4);
}
/**
* Returns the collected timings.
*
* @return array<string, float>
*/
public static function timings(): array
{
return self::$timings;
}
}
+15 -4
View File
@@ -15,15 +15,20 @@ use Symfony\Component\Console\Output\OutputInterface;
/**
* @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.
*/
public function __construct(
private InputInterface $input,
private OutputInterface $output,
private TestSuite $testSuite,
private readonly InputInterface $input,
private readonly OutputInterface $output,
private readonly TestSuite $testSuite,
) {}
/**
@@ -31,10 +36,16 @@ final readonly class EnsureTeamCityEnabled implements ConfiguredSubscriber
*/
public function notify(Configured $event): void
{
if (self::$registered) {
return;
}
if (! $this->input->hasParameterOption('--teamcity')) {
return;
}
self::$registered = true;
$flowId = getenv('FLOW_ID');
$flowId = is_string($flowId) ? (int) $flowId : getmypid();
+6
View File
@@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered;
@@ -40,11 +41,16 @@ final class StateGenerator
}
foreach ($testResult->testFailedEvents() as $testResultEvent) {
if ($testResultEvent instanceof Failed) {
$state->add(TestResult::fromPestParallelTestCase(
$testResultEvent->test(),
TestResult::FAIL,
$testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
}
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);
@@ -1,7 +0,0 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>
@@ -1,7 +0,0 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
Pest Testing Framework 4.4.4.
Pest Testing Framework 4.7.8.
USAGE: pest <file> [options]
@@ -49,6 +49,7 @@
EXECUTION OPTIONS:
--parallel ........................................... Run tests in parallel
--update-snapshots Update snapshots for tests using the "toMatchSnapshot" expectation
--update-shards Update shards.json with test timing data for time-balanced sharding
--globals-backup ................. Backup and restore $GLOBALS for each test
--static-backup ......... Backup and restore static properties for each test
--strict-coverage ................... Be strict about code coverage metadata
@@ -90,7 +91,11 @@
--cache-result ............................ Write test results to cache file
--do-not-cache-result .............. Do not write test results to cache file
--order-by [order] Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size
--resolve-dependencies ...................... Alias for "--order-by depends"
--ignore-dependencies .................... Alias for "--order-by no-depends"
--random-order ............................... Alias for "--order-by random"
--random-order-seed [N] Use the specified random seed when running tests in random order
--reverse-order ............................. Alias for "--order-by reverse"
REPORTING OPTIONS:
--colors=[flag] ......... Use colors in output ("never", "auto" or "always")
@@ -1,3 +1,3 @@
Pest Testing Framework 4.4.4.
Pest Testing Framework 4.7.8.
+7 -4
View File
@@ -4,7 +4,6 @@
✓ preset → strict → ignoring ['usleep']
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
✓ globals
✓ contracts
PASS Tests\Environments\Windows
✓ global functions are loaded
@@ -137,6 +136,11 @@
✓ describe()->with() preserves depends → first with (9)
✓ describe()->with() preserves depends → second with (9)
PASS Tests\Features\DatasetProviderErrors
✓ reports missing datasets as errors for a single file run
✓ reports missing datasets as errors alongside passing tests
✓ reports dataset closure exceptions as errors
PASS Tests\Features\DatasetsTests - 1 todo
✓ it throws exception if dataset does not exist
✓ it throws exception if dataset already exist
@@ -271,6 +275,7 @@
✓ dataset items can mix named and sequential styles with ('Taylor', 'taylor@laravel.com')
✓ dataset items can mix named and sequential styles with ('James', 'james@laravel.com') #1
✓ dataset items can mix named and sequential styles with ('James', 'james@laravel.com') #2
✓ named parameters work with a single argument with ('Taylor')
PASS Tests\Features\Depends
✓ first
@@ -1037,8 +1042,6 @@
✓ pass with toArray
✓ pass with array
✓ pass with toSnapshot
✓ failures
✓ failures with custom message
✓ not failures
✓ multiple snapshot expectations
✓ multiple snapshot expectations with datasets with (1)
@@ -1903,4 +1906,4 @@
✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value')
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1296 passed (2977 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1297 passed (2980 assertions)
+9
View File
@@ -0,0 +1,9 @@
<?php
dataset('throws', function () {
throw new RuntimeException('boom from dataset');
});
it('x', function ($a) {
expect($a)->toBeTrue();
})->with('throws');
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
it('references a missing dataset', function ($value) {
expect($value)->toBeTruthy();
})->with('missing');
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
it('passes normally', function () {
expect(true)->toBeTrue();
});
it('references a missing dataset', function ($value) {
expect($value)->toBeTruthy();
})->with('missing');
+5 -10
View File
@@ -7,6 +7,9 @@ arch()->preset()->php()->ignoring([
'debug_backtrace',
'var_export',
'xdebug_info',
'xdebug_start_code_coverage',
'xdebug_stop_code_coverage',
'xdebug_get_code_coverage',
]);
arch()->preset()->strict()->ignoring([
@@ -17,7 +20,9 @@ arch()->preset()->security()->ignoring([
'eval',
'str_shuffle',
'exec',
'md5',
'unserialize',
'uniqid',
'extract',
'assert',
]);
@@ -26,13 +31,3 @@ arch('globals')
->expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep'])
->not->toBeUsed()
->ignoring(Expectation::class);
arch('contracts')
->expect('Pest\Contracts')
->toOnlyUse([
'NunoMaduro\Collision\Contracts',
'Pest\Factories\TestCaseMethodFactory',
'Symfony\Component\Console',
'Pest\Arch\Contracts',
'Pest\PendingCalls',
])->toBeInterfaces();
+49
View File
@@ -0,0 +1,49 @@
<?php
use Symfony\Component\Process\Process;
$run = function (string $target): array {
$process = new Process(
['php', 'bin/pest', $target],
dirname(__DIR__, 2),
['COLLISION_PRINTER' => 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'],
);
$process->run();
return [
'output' => removeAnsiEscapeSequences($process->getOutput().$process->getErrorOutput()),
'code' => $process->getExitCode(),
];
};
test('reports missing datasets as errors for a single file run', function () use ($run) {
$result = $run('tests/.tests/IssueOnly.php');
expect($result['output'])
->toContain("A dataset with the name `missing` does not exist. You can create it using `dataset('missing', ['a', 'b']);`.")
->toContain('FAILED')
->toContain('Tests: 1 failed');
expect($result['code'])->not->toBe(0);
})->skipOnWindows();
test('reports missing datasets as errors alongside passing tests', function () use ($run) {
$result = $run('tests/.tests/IssueWithPassing.php');
expect($result['output'])
->toContain("A dataset with the name `missing` does not exist. You can create it using `dataset('missing', ['a', 'b']);`.")
->toContain('1 passed')
->toContain('1 failed');
expect($result['code'])->not->toBe(0);
})->skipOnWindows();
test('reports dataset closure exceptions as errors', function () use ($run) {
$result = $run('tests/.tests/DatasetClosureThrows.php');
expect($result['output'])
->toContain('boom from dataset');
expect($result['code'])->not->toBe(0);
})->skipOnWindows();
+6
View File
@@ -542,3 +542,9 @@ test('dataset items can mix named and sequential styles', function (string $name
['James', 'james@laravel.com'],
['James', 'email' => 'james@laravel.com'],
]);
test('named parameters work with a single argument', function (string $name) {
expect($name)->toBe('Taylor');
})->with([
['name' => 'Taylor'],
]);
-12
View File
@@ -134,18 +134,6 @@ test('pass with `toSnapshot`', function () {
expect($object)->toMatchSnapshot();
});
test('failures', function () {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
expect('contain that does not match snapshot')->toMatchSnapshot();
})->throws(ExpectationFailedException::class, 'Failed asserting that two strings are identical.');
test('failures with custom message', function () {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
expect('contain that does not match snapshot')->toMatchSnapshot('oh no');
})->throws(ExpectationFailedException::class, 'oh no');
test('not failures', function () {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
+8 -1
View File
@@ -86,5 +86,12 @@ dataset('dataset_in_pest_file', ['A', 'B']);
function removeAnsiEscapeSequences(string $input): ?string
{
return preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $input);
return preg_replace(
[
'#\\x1b[[][^A-Za-z]*[A-Za-z]#', // CSI (colors, cursor, etc.)
'#\\x1b\\]8;[^\\x1b\\x07]*(?:\\x1b\\\\|\\x07)#', // OSC 8 hyperlinks
],
'',
$input,
);
}
+3 -2
View File
@@ -16,6 +16,7 @@ $run = function () {
test('parallel', function () use ($run) {
$output = $run('--exclude-group=integration');
$output = implode("\n", array_slice(explode("\n", $output), -10));
if (getenv('REBUILD_SNAPSHOTS')) {
preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches);
@@ -23,13 +24,13 @@ test('parallel', function () use ($run) {
$file = file_get_contents(__FILE__);
$file = preg_replace(
'/\$expected = \'.*?\';/',
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2926 assertions)';",
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1281 passed (2929 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2926 assertions)';
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1281 passed (2929 assertions)';
expect($output)
->toContain("Tests: {$expected}")
+2
View File
@@ -21,8 +21,10 @@ test('visual snapshot of test suite on success', function () {
return preg_replace([
'#\\x1b[[][^A-Za-z]*[A-Za-z]#',
'#\\x1b\\]8;[^\\x1b\\x07]*(?:\\x1b\\\\|\\x07)#',
'/(Tests\\\PHPUnit\\\CustomAffixes\\\InvalidTestName)([A-Za-z0-9]*)/',
], [
'',
'',
'$1',
], $process->getOutput());