Compare commits

...

345 Commits

Author SHA1 Message Date
79bc7a8257 Merge branch '4.x' into 5.x 2026-06-01 07:09:57 +01:00
40b88b62ef release: v4.7.2 2026-06-01 07:08:59 +01:00
e3361bc321 fix: don't ob_start with pao 2026-06-01 07:08:50 +01:00
fc48c1bd1e Merge branch '4.x' into 5.x 2026-06-01 06:33:35 +01:00
92e76eb5ab ci: runs ci only against stable 2026-06-01 06:32:29 +01:00
da726beffc chore: merges 4.x 2026-06-01 06:28:44 +01:00
4ef12b9aac Merge branch '4.x' into 5.x 2026-06-01 06:25:56 +01:00
bd22f478b8 chore: fixes issues with contracts and symfony 8.1 2026-06-01 06:24:42 +01:00
eeaac34cf6 release: v4.7.1 2026-06-01 05:42:12 +01:00
b9b07d8983 chore: bump dependencies 2026-06-01 05:42:03 +01:00
6aa7d2f891 fix: better fatal exceptions reporting 2026-06-01 05:41:58 +01:00
4d550cecfd Merge branch '4.x' into 5.x 2026-05-13 12:20:46 +01:00
1c21a7647a chore: fixes types 2026-05-13 12:20:00 +01:00
d649de1988 chore: add security policy 2026-05-12 02:48:25 +01:00
783ca4bcd6 chore(deps): limit dependabot to maintained branches (4.x + 5.x) 2026-05-12 02:34:08 +01:00
ba07497219 chore: enable Dependabot version updates for GitHub Actions (#1700) 2026-05-11 22:12:07 -03:00
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
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
d17be9decd types 2026-05-04 08:02:09 -03:00
b828ddcec7 chore: style 2026-05-04 07:38:50 -03:00
f859bb179d Merge branch '4.x' into 5.x 2026-05-04 07:38:40 -03:00
2fc75cfcf0 chore: updates snapshots 2026-05-03 13:09:32 -03:00
6cc48f63f8 chore: style 2026-05-03 13:06:24 -03:00
e0419d1328 release: v4.7.0 2026-05-03 12:46:24 -03:00
faa6988801 Merge pull request #1682 from pestphp/feat/tia
[4.x] TIA Engine
2026-05-03 16:27:58 +01:00
c12247fafd Revert "wip"
This reverts commit 1b168aba1c.
2026-05-03 11:45:39 -03:00
29b4452443 wip 2026-05-03 11:33:30 -03:00
1b168aba1c wip 2026-05-03 11:30:13 -03:00
6aabd977cd wip 2026-05-03 11:01:52 -03:00
a882543c53 wip 2026-05-03 10:57:28 -03:00
c250b9da4f wip 2026-05-03 10:37:17 -03:00
46bc3dc628 wip 2026-05-03 10:34:44 -03:00
d3ce498b8a wip 2026-05-03 10:31:47 -03:00
e1a4b98b71 wip 2026-05-03 10:16:10 -03:00
9afbcd5c18 wuip 2026-05-03 09:54:02 -03:00
75593b6454 wip 2026-05-03 13:37:27 +01:00
89590d6120 wip 2026-05-03 13:35:01 +01:00
fb0978c9bf wip 2026-05-03 13:26:48 +01:00
a3796daa42 wip 2026-05-03 13:24:10 +01:00
e3004db666 wip 2026-05-03 12:43:38 +01:00
99cc4e0146 wip 2026-05-02 19:33:09 +01:00
a47e6f8fef wip 2026-05-02 19:30:14 +01:00
536d79f765 wip 2026-05-02 19:20:55 +01:00
65c0fbc528 wip 2026-05-02 19:07:41 +01:00
9e4cf4b665 wip 2026-05-02 18:58:42 +01:00
7bea819978 wip 2026-05-02 18:47:26 +01:00
4280233b40 wip 2026-05-02 18:37:24 +01:00
d6db3a8a20 wip 2026-05-02 18:32:05 +01:00
51c8ce4df6 wip 2026-05-02 18:31:32 +01:00
5b8393b925 wip 2026-05-02 18:25:41 +01:00
e4d9b61fdf wip 2026-05-02 18:25:27 +01:00
e2d940cd53 wip 2026-05-02 18:25:21 +01:00
380ccd30b4 wip 2026-05-02 18:03:25 +01:00
31c200716d wip 2026-05-02 18:03:14 +01:00
6add4da543 wip 2026-05-02 18:02:20 +01:00
8ddcd3e853 wip 2026-05-02 18:02:13 +01:00
e3e178fd94 wip 2026-05-02 17:59:21 +01:00
7b1ec9f003 wip 2026-05-02 17:59:13 +01:00
1e48c5d473 wip 2026-05-02 17:59:00 +01:00
d00ec95dd9 wip 2026-05-02 17:58:55 +01:00
89f3d6cb39 wip 2026-05-02 17:45:54 +01:00
a07a2e512a wip 2026-05-02 17:39:15 +01:00
57eecb2b3d wip 2026-05-02 17:38:12 +01:00
9f804dc954 wip 2026-05-02 17:38:08 +01:00
7cbad4c589 wip 2026-05-02 17:38:01 +01:00
5cae93b059 wip 2026-05-02 17:37:56 +01:00
df829ad19d wip 2026-05-02 17:37:47 +01:00
635460653c wip 2026-05-02 17:37:34 +01:00
1aa80dc398 wip 2026-05-02 17:18:35 +01:00
8a14056111 wip 2026-05-02 17:15:46 +01:00
f247dd8e7b wip 2026-05-02 17:11:49 +01:00
1c7c9754fd wip 2026-05-02 17:07:08 +01:00
5f37939fda wip 2026-05-02 17:02:11 +01:00
28305fcb7a wip 2026-05-02 16:35:52 +01:00
5242803694 wip 2026-05-02 15:54:00 +01:00
925935a7e8 wip 2026-05-02 15:33:38 +01:00
460401c379 wip 2026-05-02 15:26:58 +01:00
348b439172 wip 2026-05-02 15:15:53 +01:00
a4e77766c5 wip 2026-05-02 15:07:51 +01:00
4a8c2d7d78 wip 2026-05-02 15:03:44 +01:00
7d51601120 wip 2026-05-02 14:15:37 +01:00
631bbe318b wip 2026-05-02 13:43:32 +01:00
9b7c15d5b6 wip 2026-05-02 12:03:35 +01:00
872796bd9b wip 2026-05-02 12:00:47 +01:00
c38d32ae86 wip 2026-05-02 09:49:33 +01:00
6407c4f78f wip 2026-05-02 01:58:39 +01:00
6e1bf63f6a wip 2026-05-02 01:40:35 +01:00
1d3e8bb5dd wip 2026-05-02 01:03:06 +01:00
3cc9b169e3 wip 2026-05-02 00:52:57 +01:00
c4911d046b wip 2026-05-02 00:06:04 +01:00
d0295f6168 wip 2026-05-01 23:59:25 +01:00
21efbc3107 wip 2026-05-01 22:55:38 +01:00
e59b99cd73 wip 2026-05-01 22:51:55 +01:00
bf48e20880 wip 2026-05-01 22:36:15 +01:00
53db68e005 wip 2026-05-01 22:31:00 +01:00
34f1e9a7f2 fix 2026-05-01 21:51:09 +01:00
57fd5ce042 wip 2026-05-01 21:50:56 +01:00
3bcabfb63b fix 2026-05-01 21:50:52 +01:00
aa3a7c303a wip 2026-05-01 21:32:59 +01:00
5c08a135f7 wip 2026-05-01 21:30:44 +01:00
6e0e030d71 wip 2026-05-01 21:22:33 +01:00
b2c07561e7 wip 2026-05-01 20:54:24 +01:00
97600b6f0b wip 2026-05-01 20:53:40 +01:00
8a51f15d65 wip 2026-05-01 20:45:51 +01:00
a349f53964 wip 2026-05-01 20:42:14 +01:00
a725e774c0 wip 2026-05-01 20:28:39 +01:00
bed5e5b54a wip 2026-05-01 20:02:46 +01:00
45b1d4ce20 wip 2026-05-01 19:50:54 +01:00
d106b70766 wip 2026-05-01 17:24:22 +01:00
6ac6c1518e wip 2026-05-01 17:17:33 +01:00
fda515a17f wip 2026-05-01 16:42:01 +01:00
0a97d3a288 asd 2026-05-01 15:33:06 +01:00
3802fa80e6 asd 2026-05-01 15:19:19 +01:00
5c3cbc14d2 wip 2026-05-01 15:07:10 +01:00
6b9c768172 wip 2026-05-01 14:39:23 +01:00
4a2fc179ae asd 2026-05-01 13:54:25 +01:00
b5bb2139dc wqdqwd 2026-05-01 12:57:12 +01:00
07416a3c61 wip 2026-05-01 03:30:28 +01:00
30b94e3034 qdw 2026-05-01 02:10:08 +01:00
be34eecb2f wip 2026-05-01 01:55:18 +01:00
5d9f95f8d4 qwdqwd 2026-05-01 01:44:08 +01:00
48b70a03d5 wip 2026-05-01 01:32:48 +01:00
4b8642b972 wip 2026-05-01 00:48:31 +01:00
8711d51eac fix 2026-05-01 00:19:44 +01:00
58dfb6da64 wip 2026-04-30 22:12:53 +01:00
d7735d1faa wip 2026-04-30 22:00:56 +01:00
6b59166f3c wip 2026-04-30 21:08:00 +01:00
3a26028d17 wip 2026-04-30 20:58:06 +01:00
3c91bf4ad2 wip 2026-04-30 20:51:57 +01:00
6a434be0f6 wip 2026-04-30 20:45:36 +01:00
f355b99bbf wip 2026-04-29 22:59:56 +01:00
95a00341e9 wip for now 2026-04-29 02:22:37 +01:00
466259646d wip 2026-04-28 22:12:42 +01:00
00f8d56083 wip 2026-04-28 21:41:20 +01:00
ca2dca592d wup 2026-04-28 21:34:40 +01:00
405d8d4406 wip 2026-04-28 21:28:46 +01:00
b944ee5841 wip 2026-04-27 19:15:42 +01:00
f4e22dcafe wip 2026-04-27 18:57:41 +01:00
339c1e8cac wip 2026-04-27 18:14:10 +01:00
d4c7362132 wip 2026-04-27 16:56:27 +01:00
81bfdbf8fe wip 2026-04-27 13:16:05 +01:00
f45cbf43c5 wip 2026-04-27 13:11:48 +01:00
b9088d23fb wip 2026-04-27 13:03:07 +01:00
7250185423 wip 2026-04-27 12:22:05 +01:00
e457eb0e9c wip 2026-04-27 11:15:59 +01:00
48357c6f30 wip 2026-04-27 10:30:08 +01:00
b46f051550 wip 2026-04-23 17:32:27 -07:00
3d3c5d41ac wip 2026-04-23 12:29:24 -07:00
caabebf2a1 wip 2026-04-23 10:56:17 -07:00
470a5833d4 wip 2026-04-23 10:30:44 -07:00
c1feefbb9e wip 2026-04-23 09:44:12 -07:00
e876dba8ba wip 2026-04-23 09:29:56 -07:00
d9c18f9c02 wip 2026-04-22 09:03:10 -07:00
660b57b365 wip 2026-04-22 08:42:32 -07:00
68527c996f wip 2026-04-22 08:25:38 -07:00
c6a42a2b28 wip 2026-04-22 08:07:52 -07:00
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
856a370032 style 2026-04-21 09:44:26 -07:00
e24882c486 wip 2026-04-21 09:41:19 -07:00
51fc380789 wip 2026-04-21 09:40:01 -07:00
f6609f4039 wip 2026-04-21 08:36:41 -07:00
2941f9821f wip 2026-04-21 08:15:24 -07:00
ed399af43e wip 2026-04-21 07:41:50 -07:00
0d66dc4322 chore: removes https 2026-04-21 07:26:19 -07:00
7e4280bf83 chore: improves feedback 2026-04-21 07:13:08 -07:00
a5915b16ab wip 2026-04-20 20:58:38 -07:00
1476b529a1 wip 2026-04-20 19:56:23 -07:00
2892341c28 wip 2026-04-20 14:28:18 -07:00
59e781e77b wip 2026-04-20 13:48:05 -07:00
55a3394f8c wip 2026-04-20 13:31:43 -07:00
0d99c33c4e wip 2026-04-20 13:16:59 -07:00
adc5aae6f8 wip 2026-04-20 13:00:41 -07:00
980667e845 wip 2026-04-20 12:25:39 -07:00
8c849c5f40 fix: missing warning 2026-04-20 12:18:32 -07:00
47f1fc2d94 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
9c8033d60c feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
42d1092a9e feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
c7e32f5d33 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
d379128cc4 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
f09d6f2064 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
494cc6e2a4 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
f52a455773 fix 2026-04-20 10:09:07 -07:00
184f5d2742 feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
1d81069a2a feat(tia): continues to work on poc 2026-04-20 10:09:07 -07:00
4b9bb77b54 feat(tia): adds poc 2026-04-20 10:09:07 -07:00
c440031e28 feat(tia): adds poc 2026-04-20 10:09:07 -07:00
18bbca748f Merge branch '4.x' into 5.x 2026-04-18 07:03:46 -07:00
bff44562a9 release: v4.6.3 2026-04-18 06:51:25 -07:00
9ebb990f96 chore: bumps phpunit 2026-04-18 06:51:17 -07:00
f142aad8ad Merge branch '4.x' into 5.x 2026-04-17 19:35:53 -07:00
cabff738f7 release: v4.6.2 2026-04-17 19:32:23 -07:00
0746173a32 chore: bumps phpunit 2026-04-17 19:32:18 -07:00
74a28d4f5e fix: wrapper runner 2026-04-17 07:29:03 -07:00
6053e15d00 Merge branch '4.x' into 5.x 2026-04-17 06:07:14 -07:00
87db0b4847 release: v4.6.1 2026-04-15 09:03:09 -07:00
6ba373a772 chore: bumps phpunit 2026-04-15 08:49:34 -07:00
945d476409 fix: allow to update individual screenshots 2026-04-15 08:34:06 -07:00
a8cf0fe2cb chore: improves CI 2026-04-15 08:20:50 -07:00
2ae072bb95 feat: makes boot time much faster 2026-04-15 07:47:38 -07:00
59d066950c chore: missing header 2026-04-15 07:47:22 -07:00
0dd1aa72ef fix: updating snapshots in --parallel 2026-04-15 07:22:10 -07:00
4e03cd3edb release: v4.6.0 2026-04-14 10:23:26 -07:00
eeab24e2bb Merge pull request #1671 from pestphp/feat/time-based-sharding
[4.x] Time based sharding
2026-04-14 18:18:09 +01:00
9b64d5425a removes time balanced 2026-04-14 10:12:57 -07:00
0acab1cbb4 wip 2026-04-14 09:53:57 -07:00
e616eab9fb wip 2026-04-14 09:36:38 -07:00
7cbb1fcdb2 wip 2026-04-14 09:29:41 -07:00
cb5f6e1bd2 chore: style 2026-04-14 09:17:18 -07:00
985dadd934 update 2026-04-14 09:16:32 -07:00
10aee6045c feat(time-based-sharding): updates exception name 2026-04-14 09:08:52 -07:00
4ac14b2528 feat(time-based-sharding): updates plugin 2026-04-14 08:34:41 -07:00
2d649d765f chore: adjusts tests 2026-04-11 01:54:13 +01:00
4fb4908570 Merge branch '4.x' into 5.x 2026-04-10 22:37:24 +01:00
13c322bab3 ci: fixes incorrectCasing test 2026-04-10 20:51:40 +01:00
3855249ce9 feat: adds --flaky cli option 2026-04-10 20:03:50 +01:00
f528bd8427 feat: adds flaky 2026-04-10 19:52:31 +01:00
acd8aafa63 fix: printer with --colors 2026-04-10 19:21:49 +01:00
e8d630e774 fix: printer with --colors 2026-04-10 19:21:41 +01:00
b6385dc865 fix: namespaces on toBeCasedCorrectly 2026-04-10 19:21:31 +01:00
02dc8d7bcc chore: bumps deps 2026-04-10 19:21:18 +01:00
729f18a152 fix: stacktrace with nested with calls 2026-04-10 17:25:05 +01:00
bdf60cea91 Merge pull request #1565 from louisbels/fix-dataset-method-chaining
fix: dataset inheritance with method chaining (beforeEach()->with(), describe()->with())
2026-04-10 17:05:25 +01:00
3a8ee8291c Merge pull request #1628 from DevDavido/patch-1
fix: Parameter closure this type annotations in Functions
2026-04-10 16:58:39 +01:00
654cb726c9 Merge branch '4.x' into patch-1 2026-04-10 16:58:26 +01:00
bce26aeaad Merge pull request #1634 from dbpolito/dataset_named_params
Dataset Named Parameters
2026-04-10 16:54:57 +01:00
5948bcd71e chore: type improvements 2026-04-10 16:50:10 +01:00
89006d83a9 chore: env 2026-04-10 16:27:44 +01:00
a8e974d64a chore: updates snapshots 2026-04-10 14:42:34 +01:00
617b074049 Merge pull request #1626 from SimonBroekaert/feat/add_only-covered_option
feat: add '--only-covered' option to '--coverage'
2026-04-10 12:53:17 +01:00
2eea71a664 Merge pull request #1624 from Vmadmax/fix/unicode-filename-filter
fix: preserve Unicode characters in filenames for --filter matching
2026-04-10 12:29:40 +01:00
4b5374d507 Merge branch '4.x' into fix/unicode-filename-filter 2026-04-10 12:29:30 +01:00
9085561ece chore: runs at 9am 2026-04-10 12:24:30 +01:00
b71bfc513a chore: guards 2026-04-10 12:23:49 +01:00
75938ac9eb ci: updates deps 2026-04-10 12:18:28 +01:00
e766825f5b chore: fixes test:unit 2026-04-10 12:15:00 +01:00
8a83a1a1a9 Merge pull request #1655 from stsepelin/fix/parallel-empty-suite-reporting
fix: nested dataset discovery and parallel invalid-dataset reporting
2026-04-10 11:59:48 +01:00
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
109bb22c5e Merge pull request #1615 from smirok/parallel-teamcity-concurrency-fix
fix: enhance support for --parallel and --teamcity arguments by restoring --teamcity for ParaTest and fixing teamcity output concurrency
2026-04-10 11:42:45 +01:00
89dd212d84 Merge pull request #1580 from bibrokhim/patch-1
Add Rules to Laravel preset
2026-04-10 11:42:13 +01:00
cd07c6d966 Merge pull request #1569 from treyssatvincent/patch-1
add missing classes before toExtend on laravel preset
2026-04-10 11:41:53 +01:00
8dddb47ad5 Merge branch '4.x' into fix-dataset-method-chaining 2026-04-10 11:41:13 +01:00
3a6c2fab37 Merge pull request #1515 from yondifon/fix-trait-inheritance-detection
BugFix: Fix toUseTrait to detect inherited and nested traits
2026-04-10 11:39:56 +01:00
281dbf6cf4 Merge pull request #1455 from SimonBroekaert/feat/to_be_cased_correctly_arch_test_assertion
feat: add toBeCasedCorrectly arch test assertion
2026-04-10 11:38:46 +01:00
40c8429058 Merge pull request #1653 from orphanedrecord/fix/pest-php-stub-typo
Fix typo in Pest.php stubs
2026-04-10 11:35:41 +01:00
8dd650fd05 Merge branch '4.x' into 5.x 2026-04-09 21:39:15 +01:00
d9d46c73f8 chore: stores statically the result 2026-04-09 21:36:49 +01:00
fbca346d7c fix: types 2026-04-07 14:40:55 +01:00
3f13bca0f7 just in case 2026-04-07 14:37:13 +01:00
d3acb1c56a fix: coverage 2026-04-07 14:33:41 +01:00
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
6fdbca1226 fix: parallel testing 2026-04-06 23:37:49 +01:00
54359b895f Merge branch '4.x' into 5.x 2026-04-06 21:57:41 +01:00
e44c554a0b chore: bumps dependencies 2026-04-06 21:57:05 +01:00
44c04bfce1 chore: bumps paratest 2026-04-06 14:41:38 +01:00
271c680d3c Merge branch '4.x' into 5.x 2026-04-06 11:24:05 +01:00
9797a71dbc feat(ai): allow temporary namesapce 2026-04-03 14:43:28 +01:00
c1a54df233 feat: --ai work in progress 2026-04-03 14:04:20 +01:00
4a1d8d27b8 chore: bumps dependencies 2026-04-03 12:12:27 +01:00
0f6924984c Merge branch '4.x' into 5.x 2026-04-03 12:02:36 +01:00
ce05ee9aad release: v4.4.4 2026-04-03 12:00:04 +01:00
3d2ebdb273 bump: dependencies 2026-04-03 11:59:54 +01:00
668ca9f5de feat: adds pao 2026-04-02 15:45:13 +01:00
f47b74445b Merge pull request #1657 from morpheus7CS/pint-formatting-rules-applied-to-stubs 2026-04-02 13:04:00 +01:00
6c42e7f4ea Laravel Pint default formatting applied to Pest-php.stub 2026-04-01 16:48:14 +02:00
be3ff37517 Merge branch '4.x' of https://github.com/pestphp/pest into dataset_named_params 2026-03-26 18:08:26 -03:00
a087555383 bump: dependencies 2026-03-26 14:30:03 +00:00
4b50cb486d Restore success snapshot coverage with lower memory limit 2026-03-25 23:59:29 +02:00
f7175ecfd7 Fix parallel dataset reporting and nested fixtures 2026-03-25 23:59:29 +02:00
07737bc0b2 Fix parallel file selection and empty-suite reporting 2026-03-25 23:59:28 +02:00
f659a45311 Merge branch '4.x' into 5.x 2026-03-21 13:20:25 +00:00
e6ab897594 release: v4.4.3 2026-03-21 13:14:39 +00:00
a753b41409 chore: bumps phpunit 2026-03-21 13:14:35 +00:00
1a4c06bd6e Fix Pest comment typo while still honoring the Otwellian Waterfall 2026-03-18 20:51:45 -07:00
12c1da29ee Merge branch '4.x' into 5.x 2026-03-10 21:21:24 +00:00
5d42e8fe3a release: v4.4.2 2026-03-10 21:09:12 +00:00
9d17b872dd chore: update stubs 2026-03-10 21:09:02 +00:00
2a80101f42 chore: update styling 2026-03-10 21:06:28 +00:00
f7015fe59c chore: adjusts sponsors 2026-02-24 10:44:48 +00:00
7281e0ded7 Add SerpApi to the list of sponsors
Add SerpApi as a sponsor in the README.
2026-02-20 01:18:43 +00:00
1675dd1d41 chore: add tests for toBeCasedCorrectly() arch test 2026-02-17 19:03:46 +01:00
fa27c8daef chore: version 2026-02-17 17:52:40 +00:00
f0a08f0503 chore: missing types 2026-02-17 17:52:00 +00:00
2c040c5b1f chore: style 2026-02-17 17:45:50 +00:00
a9ce1fd739 chore: code refactor 2026-02-17 17:45:34 +00:00
3533356262 chore: updates snapshots 2026-02-17 17:44:56 +00:00
4aa41d0b14 chore: bumps dependencies 2026-02-17 17:41:38 +00:00
e4ed60085c chore: bumps dependencies 2026-02-17 17:18:45 +00:00
e2b119655d chore: point pestphp dependencies to ^5.0.0 2026-02-17 17:13:36 +00:00
fcf5baf0a9 chore: start preparing for pest 5.x 2026-02-17 16:55:03 +00:00
df7b6c8454 feat: add toBeCasedCorrectly arch test assertion 2026-02-17 17:31:02 +01:00
5de8693e3b chore: style 2026-02-17 16:15:58 +00:00
7d80f1d20e chore: removes non used files 2026-02-17 15:34:32 +00:00
b3119cc120 Merge pull request #1562 from michaelw85/patch-1
[Fix] Pass test dir to worker
2026-02-17 15:33:16 +00:00
4e294edf76 Merge pull request #1639 from imliam/patch-1
[Laravel Preset] Allow App\Http to be used in providers
2026-02-17 15:31:01 +00:00
f96a1b2786 release: v4.4.1 2026-02-17 15:27:18 +00:00
a49cf7edc5 ci: speed up ci 2026-02-17 15:21:20 +00:00
b0f6a74cb6 ci: makes jobs faster 2026-02-17 15:18:33 +00:00
aaa226f6a6 chore: tests against symfony 8 2026-02-17 15:14:45 +00:00
69cb752d02 chore: bumps dependencies 2026-02-17 15:01:37 +00:00
cf00e58b7d chore: bumps dependencies 2026-02-17 11:22:04 +00:00
1f39b28e2c Allow App\Http to be used in providers 2026-02-16 00:25:47 +01:00
9fcbca69d4 Update README.md 2026-02-13 10:41:22 +00:00
b081584ab6 Improvements 2026-02-11 18:09:09 -03:00
6966802afc Cleanup 2026-02-11 18:02:21 -03:00
c61dcad42b Dataset Named Parameters 2026-02-11 17:57:07 -03:00
ec3e0b2d33 fix: Parameter closure this type annotations in Functions.php 2026-02-09 20:48:56 +01:00
c3620840b4 feat: add '--only-covered' option to '--coverage' 2026-02-06 11:57:21 +01:00
10a19f16ba refactor: simplify regex to use Unicode properties \p{L} and \p{N 2026-02-02 09:41:54 +01:00
a956de5446 fix: preserve Unicode characters in class names for --filter matching 2026-02-02 09:01:24 +01:00
3a4329ddc7 release: 4.3.2 2026-01-28 01:01:19 +00:00
e6f511302b fix: enhance support for --parallel and --teamcity arguments by restoring --teamcity for ParaTest and fixing teamcity output concurrency 2026-01-27 16:47:24 +01:00
dd01229d7b Merge pull request #1606 from smirok/teamcity-fix-for-describe-tests-with-dataset
fix: replace `substr` with `mb_substr` in Str::beforeLast to ensure multibyte string compatibility and correct TeamCity test names for datasets in "describe" blocks
2026-01-15 01:52:01 +00:00
c7e4efcea4 fix: replace substr with mb_substr in Str::beforeLast to ensure multibyte string compatibility and correct TeamCity test names for datasets in "describe" blocks 2026-01-14 00:55:35 +01:00
df3205e814 Merge pull request #1554 from pindab0ter/feature/extend-closure-this
Specify closure this for extend
2026-01-13 01:19:47 +00:00
bc57a84e77 release: 4.3.1 2026-01-04 11:29:59 -05:00
bc39830d8a chore: removes toHaveSuspiciousCharacters from php preset 2026-01-04 11:25:57 -05:00
3a566b100e docs: why php 2026-01-04 11:04:03 -05:00
9fe61e0e56 docs: update sponsors
Removed and updated sponsor links in the README.
2026-01-03 18:07:02 -05:00
e86bec3e68 release: 4.3.0 2025-12-30 14:48:33 -05:00
58b8f3cc5d Merge pull request #1598 from Willem-Jaap/willem-jaap/pest-only-file-level
feat: add pest only function to mark each test in a file as only
2025-12-30 14:40:04 -05:00
c157b661f2 style: format 2025-12-30 09:26:35 +01:00
be90610f17 feat: add pest only function to mark each test in a file as only 2025-12-30 09:24:05 +01:00
1701a306c3 Merge pull request #1590 from pestphp/feature/intl-exception
feat: show more useful exception when `intl` extension not found
2025-12-29 22:09:16 -05:00
064ab3fc2e Merge pull request #1595 from bilboque/dirty-flag-not-found-in-help
feat: add --dirty documentation in --help
2025-12-29 22:08:12 -05:00
leo
44e315df98 feat: add --dirty documentation in --help 2025-12-22 11:02:28 +01:00
62694c14b9 chore: style 2025-12-15 11:54:24 +00:00
7c43c1c583 Merge pull request #1586 from jackbayliss/bump-checkout-action
[4.x] Bump checkout version from 5 to 6
2025-12-15 11:49:28 +00:00
6a96aed654 feat: adds phpunit@12.5 support 2025-12-15 11:48:43 +00:00
b1c997a869 feat: show more useful exception when intl extension not found 2025-12-12 12:02:00 +00:00
b4172e2c2e bump checkout version from 5 to 6 2025-12-10 14:08:06 +00:00
ae419afd36 chore: support for symfony 8.0.0 components 2025-11-28 12:04:48 +00:00
27aa305897 Merge pull request #1576 from Chris53897/feature/ci
ci: bump actions/checkout 4 => 5
2025-11-25 11:18:28 +00:00
0e7c2abe8b Add Rules to Laravel preset 2025-11-25 15:32:36 +05:00
f5820bd670 release: 4.1.5 2025-11-24 12:46:38 +00:00
41fd831153 release: 4.1.4 2025-11-24 10:25:45 +00:00
51340439e8 ci: bump actions/checkout 4 => 5 2025-11-22 12:12:15 +01:00
1a39826935 ci: tests against php 8.5 2025-11-20 02:59:27 +00:00
bd5fed9e12 add missing classes before toExtend on laravel preset
Add missing `->classes()` before `->toExtend()` on the laravel preset for 2 namespaces.

Otherwise you can't use interfaces on theses namespace.

For example, if you create an interface `YourSuperRequestContract` on the `app/Http/Requests` namespace you will get this error:

```
Expecting 'app/Http/Requests/YourSuperRequestContract.php' to extend 'Illuminate\Foundation\Http\FormRequest'.
```
2025-11-10 15:26:56 +00:00
26345fd9f4 fix: dataset inheritance with method chaining (beforeEach()->with(), describe()->with())
Fixes issue where datasets were not applied when using method chaining patterns
like beforeEach()->with([...]) or describe()->with([...]) inside nested describe blocks.

Root cause: Multiple functions were using Backtrace::file() which returns the
immediate caller's filename. This breaks when called through method chaining
because the backtrace returns internal Pest files instead of the test file.

Solution: Use Backtrace::testFile() which walks the entire backtrace to find
the actual test file being executed. This matches the pattern already used by
test() and describe() functions.

Changes in src/Functions.php:
- beforeEach(): Use testFile() to fix beforeEach()->with() pattern
- afterEach(): Use testFile() for consistency with beforeEach()
- beforeAll(): Use testFile() for better error messages
- afterAll(): Use testFile() for better error messages
- pest(): Use testFile() to fix pest()->beforeEach() pattern
- uses(): Use testFile() for consistency with pest()
- covers(): Use testFile() for correct test file context
- mutates(): Use testFile() for correct test file context

Changes in src/PendingCalls/DescribeCall.php:
- __destruct(): Force BeforeEachCall destructor before test creation
- __call(): Use $this->filename instead of Backtrace, more efficient
- __call(): Properly merge describing context for nested describe blocks

Fixes patterns:
- beforeEach()->with([...])
- describe()->with([...])
- pest()->beforeEach()->with([...]

Tests passing:
- tests/Features/Describe.php (all dataset tests)
- tests/Hooks/BeforeEachTest.php (global hook execution)
- tests/Features/Expect/toMatchSnapshot.php (28 tests)
2025-11-05 17:46:52 +01:00
ae1da79ac1 Pass test dir to worker
#1444 
Test directory argument is lost when spawning workers, add it again.
2025-11-05 17:15:51 +01:00
00990efc97 Merge pull request #1544 from Se7en-RU/fix-testdox-columns-warning
Fix Undefined array key "testdox-columns" warning
2025-11-04 07:42:57 +00:00
477d20a54f release: 4.1.3 2025-10-29 22:45:27 +00:00
b7b16096db Specify closure this for extend 2025-10-29 11:20:08 +01:00
4105e33c39 Fix Undefined array key "testdox-columns" warning
Fix Undefined array key "testdox-columns" warning
2025-10-21 11:44:55 +03:00
dc9a1e8ace BugFix: Fix toUseTrait to detect inherited and nested traits 2025-09-20 19:06:23 +01:00
213 changed files with 12146 additions and 472 deletions

13
.github/SECURITY.md vendored Normal file
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.

19
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
target-branch: "5.x"
groups:
github-actions:
patterns:
- "*"

View File

@ -2,9 +2,17 @@ name: Static Analysis
on: on:
push: push:
branches: [5.x]
pull_request: pull_request:
schedule: schedule:
- cron: '0 0 * * *' - cron: '0 9 * * *'
concurrency:
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
static: static:
@ -12,25 +20,40 @@ jobs:
name: Static Tests name: Static Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
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@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 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
- name: Get Composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
static-php-8.4-${{ matrix.dependency-version }}-composer-
static-php-8.4-composer-
- name: Install Dependencies - name: Install Dependencies
run: composer update --prefer-stable --no-interaction --no-progress --ansi run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check - name: Profanity Check
run: composer test:profanity run: composer test:profanity
@ -41,8 +64,5 @@ jobs:
- name: Type Coverage - name: Type Coverage
run: composer test:type:coverage run: composer test:type:coverage
- name: Refacto
run: composer test:refacto
- name: Style - name: Style
run: composer test:lint run: composer test:lint

View File

@ -2,35 +2,60 @@ name: Tests
on: on:
push: push:
branches: [5.x]
pull_request: pull_request:
schedule:
- cron: '0 9 * * *'
concurrency:
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
tests: tests:
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest' if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
os: [ubuntu-latest, macos-latest] # windows-latest os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['7.3'] symfony: ['8.0']
php: ['8.3', '8.4'] php: ['8.4', '8.5']
dependency_version: [prefer-stable] dependency_version: [prefer-stable]
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@v4 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets extensions: sockets
- name: Get Composer cache directory
id: composer-cache
shell: bash
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
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-
- name: Setup Problem Matches - name: Setup Problem Matches
run: | run: |
echo "::add-matcher::${{ runner.tool_cache }}/php.json" echo "::add-matcher::${{ runner.tool_cache }}/php.json"

View File

@ -1,14 +0,0 @@
# Well documented Makefiles
DEFAULT_GOAL := help
help:
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
build: ## Build all docker images. Specify the command e.g. via make build ARGS="--build-arg PHP=8.2"
docker compose build $(ARGS)
##@ [Application]
install: ## Install the composer dependencies
docker compose run --rm composer install
test: ## Run the tests
docker compose run --rm composer test

View File

@ -5,6 +5,8 @@
<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>
<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>
</p> </p>
@ -30,25 +32,23 @@ We cannot thank our sponsors enough for their incredible support in funding Pest
### Platinum Sponsors ### Platinum Sponsors
- **[Laracasts](https://laracasts.com/?ref=pestphp)** - **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[NativePHP](https://nativephp.com/mobile?ref=pestphp.com)** - **[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 ### Gold Sponsors
- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[CMS Max](https://cmsmax.com/?ref=pestphp)** - **[CMS Max](https://cmsmax.com/?ref=pestphp)**
### Premium Sponsors ### Premium Sponsors
- [Forge](https://forge.laravel.com/?ref=pestphp) - [Zapiet](https://zapiet.com/?ref=pestphp)
- [Zapiet](https://www.zapiet.com/?ref=pestphp)
- [Localazy](https://localazy.com/?ref=pestphp)
- [Load Forge](https://loadforge.com/?ref=pestphp) - [Load Forge](https://loadforge.com/?ref=pestphp)
- [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp) - [Route4Me](https://route4me.com/pt?ref=pestphp)
- [Route4Me](https://www.route4me.com/?ref=pestphp) - [Nerdify](https://getnerdify.com/?ref=pestphp)
- [Devtools for Livewire](https://devtools-for-livewire.com/?ref=pestphp)
- [Nerdify](https://www.getnerdify.com/?ref=pestphp)
- [Akaunting](https://akaunting.com/?ref=pestphp) - [Akaunting](https://akaunting.com/?ref=pestphp)
- [LambdaTest](https://lambdatest.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)**.

View File

@ -3,13 +3,16 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Contracts\Restarter;
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
use Pest\TestCaseMethodFilters\NotesTestCaseFilter; use Pest\TestCaseMethodFilters\NotesTestCaseFilter;
use Pest\TestCaseMethodFilters\PrTestCaseFilter; use Pest\TestCaseMethodFilters\PrTestCaseFilter;
use Pest\TestCaseMethodFilters\FlakyTestCaseFilter;
use Pest\TestCaseMethodFilters\TodoTestCaseFilter; use Pest\TestCaseMethodFilters\TodoTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
@ -23,6 +26,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
$dirty = false; $dirty = false;
$todo = false; $todo = false;
$flaky = false;
$notes = false; $notes = false;
foreach ($arguments as $key => $value) { foreach ($arguments as $key => $value) {
@ -57,6 +61,11 @@ use Symfony\Component\Console\Output\ConsoleOutput;
unset($arguments[$key]); unset($arguments[$key]);
} }
if ($value === '--flaky') {
$flaky = true;
unset($arguments[$key]);
}
if ($value === '--notes') { if ($value === '--notes') {
$notes = true; $notes = true;
unset($arguments[$key]); unset($arguments[$key]);
@ -135,6 +144,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $rootPath = dirname($autoloadPath, 2);
$input = new ArgvInput; $input = new ArgvInput;
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
@ -150,6 +160,10 @@ use Symfony\Component\Console\Output\ConsoleOutput;
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter); $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter);
} }
if ($flaky) {
$testSuite->tests->addTestCaseMethodFilter(new FlakyTestCaseFilter);
}
if ($notes) { if ($notes) {
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter); $testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
} }
@ -181,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try { try {
$kernel = Kernel::boot($testSuite, $input, $output); $kernel = Kernel::boot($testSuite, $input, $output);
$container = Container::getInstance();
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
assert($restarter instanceof Restarter);
$restarter->maybeRestart($rootPath, $originalArguments);
}
$result = $kernel->handle($originalArguments, $arguments); $result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate(); $kernel->terminate();

239
bin/pest-tia-vite-deps.mjs Normal file
View File

@ -0,0 +1,239 @@
#!/usr/bin/env node
import { readdir, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve, relative, extname, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PAGE_EXTENSIONS = new Set([
'.vue', '.svelte',
'.tsx', '.jsx',
'.ts', '.js',
'.mts', '.cts', '.mjs', '.cjs',
])
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
]
async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const path = projectRequire.resolve('rolldown')
return await import(pathToFileURL(path).href)
}
async function readJsonWithComments(path) {
const raw = await readFile(path, 'utf8')
const stripped = raw
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
return JSON.parse(stripped)
}
async function loadAliasFromTsconfig() {
const alias = {}
for (const name of ['tsconfig.json', 'jsconfig.json']) {
const p = join(PROJECT_ROOT, name)
if (!existsSync(p)) continue
let cfg
try { cfg = await readJsonWithComments(p) } catch { continue }
const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.')
const paths = cfg?.compilerOptions?.paths ?? {}
for (const [key, targets] of Object.entries(paths)) {
if (!key.endsWith('/*')) continue
const t0 = Array.isArray(targets) ? targets[0] : null
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
const aliasKey = key.slice(0, -2)
if (alias[aliasKey] !== undefined) continue
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
}
}
return alias
}
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
const out = []
const walk = async (dir) => {
let entries
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) { await walk(full); continue }
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
}
}
await walk(pagesDir)
return out
}
async function discoverPagesDir() {
const override = process.env.TIA_VITE_PAGES_DIR
if (override && override.length > 0) {
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
}
for (const rel of PAGE_DIR_CANDIDATES) {
const abs = resolve(PROJECT_ROOT, rel)
if (!existsSync(abs)) continue
const files = await listPageFiles(abs)
if (files.length > 0) return abs
}
return null
}
function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel)
return rel.slice(0, rel.length - ext.length)
}
function isLocalSpecifier(source, aliasKeys) {
if (source.startsWith('.') || source.startsWith('/')) return true
for (const key of aliasKeys) {
if (source === key || source.startsWith(key + '/')) return true
}
return false
}
async function main() {
const pagesDir = await discoverPagesDir()
if (pagesDir === null) {
process.stdout.write('{}')
return
}
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {
process.stdout.write('{}')
return
}
const { rolldown } = await loadRolldown()
const alias = await loadAliasFromTsconfig()
const aliasKeys = Object.keys(alias)
const graph = new Map()
const collector = {
name: 'pest-tia-collector',
moduleParsed(info) {
const id = info.id
if (!id || id.startsWith('\0')) return
const deps = new Set()
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
graph.set(id, deps)
},
}
const externalBare = {
name: 'pest-tia-external-bare',
resolveId(source) {
if (!source) return null
if (isLocalSpecifier(source, aliasKeys)) return null
return { id: source, external: true }
},
}
const assetStub = {
name: 'pest-tia-asset-stub',
load(id) {
if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
}
return null
},
}
const input = Object.create(null)
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
const bundle = await rolldown({
input,
cwd: PROJECT_ROOT,
resolve: {
alias,
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
},
transform: { jsx: 'preserve' },
treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
try {
await bundle.generate({ format: 'esm' })
} finally {
await bundle.close()
}
const reverse = new Map()
const transitiveCache = new Map()
const computeTransitive = (id, stack) => {
const cached = transitiveCache.get(id)
if (cached) return cached
if (stack.has(id)) return null
stack.add(id)
const acc = new Set()
const deps = graph.get(id)
if (deps) {
for (const dep of deps) {
if (!dep || dep.startsWith('\0')) continue
if (dep.startsWith(PROJECT_ROOT)) {
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
acc.add(rel)
}
if (stack.has(dep)) continue
const child = computeTransitive(dep, stack)
if (child) for (const r of child) acc.add(r)
}
}
stack.delete(id)
transitiveCache.set(id, acc)
return acc
}
for (const page of pages) {
const pageComponent = componentNameFor(page, pagesDir)
const reachable = computeTransitive(page, new Set())
if (!reachable) continue
for (const rel of reachable) {
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
}
}
const payload = Object.create(null)
const keys = [...reverse.keys()].sort()
for (const key of keys) {
payload[key] = [...reverse.get(key)].sort()
}
process.stdout.write(JSON.stringify(payload))
}
try {
void pathToFileURL
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}

View File

@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel; use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
@ -58,6 +59,15 @@ $bootPest = (static function (): void {
} }
} }
$container = Container::getInstance();
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
}
assert(isset($getopt['status-file']) && is_string($getopt['status-file'])); assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb'); $statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile)); assert(is_resource($statusFile));
@ -86,7 +96,7 @@ $bootPest = (static function (): void {
$getopt['teamcity-file'] ?? null, $getopt['teamcity-file'] ?? null,
$getopt['testdox-file'] ?? null, $getopt['testdox-file'] ?? null,
isset($getopt['testdox-color']), isset($getopt['testdox-color']),
(int) $getopt['testdox-columns'] ?? null, (int) ($getopt['testdox-columns'] ?? null),
); );
while (true) { while (true) {

View File

@ -17,20 +17,20 @@
} }
], ],
"require": { "require": {
"php": "^8.3.0", "php": "^8.4",
"brianium/paratest": "^7.14.0", "brianium/paratest": "^7.22.4",
"nunomaduro/collision": "^8.8.2", "nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.3.1", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-mutate": "^5.0.0",
"pestphp/pest-plugin-profanity": "^4.1.0", "pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^12.4.0", "phpunit/phpunit": "^13.1.8",
"symfony/process": "^7.3.4" "symfony/process": "^8.1.0"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.4.0", "phpunit/phpunit": ">13.1.8",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@ -50,15 +50,20 @@
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch", "Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
"Tests\\": "tests/PHPUnit/" "Tests\\": "tests/PHPUnit/"
}, },
"classmap": [
"tests/Fixtures/Arch/ToBeCasedCorrectly/IncorrectCasing/incorrectCasing.php"
],
"files": [ "files": [
"tests/Autoload.php" "tests/Autoload.php"
] ]
}, },
"require-dev": { "require-dev": {
"pestphp/pest-dev-tools": "^4.0.0", "mrpunyapal/peststan": "^0.2.10",
"pestphp/pest-plugin-browser": "^4.1.1", "laravel/pao": "^1.0.6",
"pestphp/pest-plugin-type-coverage": "^4.0.2", "pestphp/pest-dev-tools": "^5.0.0",
"psy/psysh": "^0.12.12" "pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^5.0.0",
"psy/psysh": "^0.12.23"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
@ -73,10 +78,14 @@
"bin/pest" "bin/pest"
], ],
"scripts": { "scripts": {
"refacto": "rector", "lint": [
"lint": "pint --parallel", "rector",
"test:refacto": "rector --dry-run", "pint --parallel"
"test:lint": "pint --parallel --test", ],
"test:lint": [
"rector --dry-run",
"pint --parallel --test"
],
"test:profanity": "php bin/pest --profanity --compact", "test:profanity": "php bin/pest --profanity --compact",
"test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug", "test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug",
"test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100", "test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100",
@ -86,7 +95,6 @@
"test:integration": "php bin/pest --group=integration -v", "test:integration": "php bin/pest --group=integration -v",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [ "test": [
"@test:refacto",
"@test:lint", "@test:lint",
"@test:type:check", "@test:type:check",
"@test:type:coverage", "@test:type:coverage",
@ -116,6 +124,7 @@
"Pest\\Plugins\\Verbose", "Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version", "Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard", "Pest\\Plugins\\Shard",
"Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel" "Pest\\Plugins\\Parallel"
] ]
}, },

View File

@ -1,14 +0,0 @@
version: "3.8"
services:
php:
build:
context: ./docker
volumes:
- .:/var/www/html
composer:
build:
context: ./docker
volumes:
- .:/var/www/html
entrypoint: ["composer"]

View File

@ -1,6 +1,39 @@
<?php <?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); declare(strict_types=1);
/* /*
* This file is part of PHPUnit. * This file is part of PHPUnit.
* *
@ -14,6 +47,9 @@ namespace PHPUnit\Logging\JUnit;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use Pest\Logging\Converter;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\EventFacadeIsSealedException; use PHPUnit\Event\EventFacadeIsSealedException;
@ -50,7 +86,7 @@ final class JunitXmlLogger
{ {
private readonly Printer $printer; private readonly Printer $printer;
private readonly \Pest\Logging\Converter $converter; // pest-added private readonly Converter $converter; // pest-added
private DOMDocument $document; private DOMDocument $document;
@ -108,7 +144,7 @@ final class JunitXmlLogger
public function __construct(Printer $printer, Facade $facade) public function __construct(Printer $printer, Facade $facade)
{ {
$this->printer = $printer; $this->printer = $printer;
$this->converter = new \Pest\Logging\Converter(\Pest\Support\Container::getInstance()->get(\Pest\TestSuite::class)->rootPath); // pest-added $this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath); // pest-added
$this->registerSubscribers($facade); $this->registerSubscribers($facade);
$this->createDocument(); $this->createDocument();

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;
}
}

View File

@ -1,11 +1,5 @@
parameters: parameters:
ignoreErrors: ignoreErrors:
-
message: '#^Parameter \#1 of callable callable\(Pest\\Expectation\<string\|null\>\)\: Pest\\Arch\\Contracts\\ArchExpectation expects Pest\\Expectation\<string\|null\>, Pest\\Expectation\<string\|null\> given\.$#'
identifier: argument.type
count: 1
path: src/ArchPresets/AbstractPreset.php
- -
message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#' message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#'
identifier: trait.unused identifier: trait.unused
@ -24,12 +18,6 @@ parameters:
count: 1 count: 1
path: src/Concerns/Testable.php path: src/Concerns/Testable.php
-
message: '#^Loose comparison using \!\= between \(Closure\|null\) and false will always evaluate to false\.$#'
identifier: notEqual.alwaysFalse
count: 1
path: src/Expectation.php
- -
message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\<TAndValue\> but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\<TAndValue of mixed\>\.$#' message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\<TAndValue\> but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\<TAndValue of mixed\>\.$#'
identifier: return.type identifier: return.type
@ -102,78 +90,12 @@ parameters:
count: 1 count: 1
path: src/PendingCalls/TestCall.php path: src/PendingCalls/TestCall.php
-
message: '#^Parameter \#1 \$argv of class Symfony\\Component\\Console\\Input\\ArgvInput constructor expects list\<string\>\|null, array\<int, string\> given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel.php
-
message: '#^Parameter \#13 \$testRunnerTriggeredDeprecationEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestRunner\\DeprecationTriggered\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#14 \$testRunnerTriggeredWarningEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestRunner\\WarningTriggered\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#15 \$errors of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#16 \$deprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#17 \$notices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#18 \$warnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#19 \$phpDeprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#20 \$phpNotices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#21 \$phpWarnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\TestRunner\\TestResult\\Issues\\Issue\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
- -
message: '#^Parameter \#4 \$testErroredEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\AfterLastTestMethodErrored\|PHPUnit\\Event\\Test\\BeforeFirstTestMethodErrored\|PHPUnit\\Event\\Test\\Errored\>, array given\.$#' message: '#^Parameter \#4 \$testErroredEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\AfterLastTestMethodErrored\|PHPUnit\\Event\\Test\\BeforeFirstTestMethodErrored\|PHPUnit\\Event\\Test\\Errored\>, array given\.$#'
identifier: argument.type identifier: argument.type
count: 1 count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#5 \$testFailedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\Failed\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
- -
message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestSuite\\Skipped\>, array given\.$#' message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\TestSuite\\Skipped\>, array given\.$#'
identifier: argument.type identifier: argument.type

View File

@ -0,0 +1,5 @@
services:
-
class: Pest\PHPStan\HigherOrderExpectationTypeExtension
tags:
- phpstan.broker.expressionTypeResolverExtension

View File

@ -1,5 +1,7 @@
includes: includes:
- phpstan-baseline.neon - phpstan-baseline.neon
- phpstan-pest-extension.neon
- vendor/mrpunyapal/peststan/extension.neon
parameters: parameters:
level: 7 level: 7
@ -7,6 +9,3 @@ parameters:
- src - src
reportUnmatchedIgnoredErrors: false reportUnmatchedIgnoredErrors: false
ignoreErrors:
- "#type mixed is not subtype of native#"

View File

@ -2,7 +2,10 @@
declare(strict_types=1); declare(strict_types=1);
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
use Rector\Config\RectorConfig; use Rector\Config\RectorConfig;
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector; use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
return RectorConfig::configure() return RectorConfig::configure()
@ -12,6 +15,9 @@ return RectorConfig::configure()
->withSkip([ ->withSkip([
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php', __DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
ReturnNeverTypeRector::class, ReturnNeverTypeRector::class,
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
NarrowObjectReturnTypeRector::class,
RemoveParentDelegatingConstructorRector::class,
]) ])
->withPreparedSets( ->withPreparedSets(
deadCode: true, deadCode: true,

View File

@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], 'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
}; };
?> ?>

View File

@ -53,7 +53,7 @@ abstract class AbstractPreset // @pest-arch-ignore-line
/** /**
* Runs the given callback for each namespace. * Runs the given callback for each namespace.
* *
* @param callable(Expectation<string|null>): ArchExpectation ...$callbacks * @param callable(Expectation<string>): ArchExpectation ...$callbacks
*/ */
final public function eachUserNamespace(callable ...$callbacks): void final public function eachUserNamespace(callable ...$callbacks): void
{ {

View File

@ -69,6 +69,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('Request'); ->toHaveSuffix('Request');
$this->expectations[] = expect('App\Http\Requests') $this->expectations[] = expect('App\Http\Requests')
->classes()
->toExtend('Illuminate\Foundation\Http\FormRequest'); ->toExtend('Illuminate\Foundation\Http\FormRequest');
$this->expectations[] = expect('App\Http\Requests') $this->expectations[] = expect('App\Http\Requests')
@ -118,6 +119,7 @@ final class Laravel extends AbstractPreset
->toHaveMethod('handle'); ->toHaveMethod('handle');
$this->expectations[] = expect('App\Notifications') $this->expectations[] = expect('App\Notifications')
->classes()
->toExtend('Illuminate\Notifications\Notification'); ->toExtend('Illuminate\Notifications\Notification');
$this->expectations[] = expect('App') $this->expectations[] = expect('App')
@ -128,6 +130,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('ServiceProvider'); ->toHaveSuffix('ServiceProvider');
$this->expectations[] = expect('App\Providers') $this->expectations[] = expect('App\Providers')
->classes()
->toExtend('Illuminate\Support\ServiceProvider'); ->toExtend('Illuminate\Support\ServiceProvider');
$this->expectations[] = expect('App\Providers') $this->expectations[] = expect('App\Providers')
@ -150,7 +153,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('Controller'); ->toHaveSuffix('Controller');
$this->expectations[] = expect('App\Http') $this->expectations[] = expect('App\Http')
->toOnlyBeUsedIn('App\Http'); ->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
$this->expectations[] = expect('App\Http\Controllers') $this->expectations[] = expect('App\Http\Controllers')
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']); ->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);

View File

@ -4,9 +4,6 @@ declare(strict_types=1);
namespace Pest\ArchPresets; namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/** /**
* @internal * @internal
*/ */
@ -92,9 +89,5 @@ final class Php extends AbstractPreset
'xdebug_var_dump', 'xdebug_var_dump',
'trap', 'trap',
])->not->toBeUsed(); ])->not->toBeUsed();
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->not->toHaveSuspiciousCharacters(),
);
} }
} }

View File

@ -83,7 +83,7 @@ final class BootFiles implements Bootstrapper
private function bootDatasets(string $testsPath): void private function bootDatasets(string $testsPath): void
{ {
assert(strlen($testsPath) > 0); assert($testsPath !== '');
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php'); $files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');

View File

@ -21,6 +21,7 @@ final class BootOverrides implements Bootstrapper
'Runner/Filter/NameFilterIterator.php', 'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php', 'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php', 'Runner/TestSuiteLoader.php',
'Runner/TestSuiteSorter.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php', 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php', 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'TextUI/TestSuiteFilterProcessor.php', 'TextUI/TestSuiteFilterProcessor.php',

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use PHPUnit\TextUI\Configuration\Builder;
/**
* @internal
*/
final class BootPhpUnitConfiguration implements Bootstrapper
{
public function boot(): void
{
(new Builder)->build(['pest']);
}
}

View File

@ -25,6 +25,17 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
Subscribers\EnsureTiaStarts::class,
Subscribers\EnsureTiaEnds::class,
Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
]; ];
/** /**

View File

@ -8,6 +8,8 @@ use Closure;
/** /**
* @internal * @internal
*
* @template T of object
*/ */
trait Extendable trait Extendable
{ {
@ -20,6 +22,8 @@ trait Extendable
/** /**
* Register a new extend. * Register a new extend.
*
* @param-closure-this T $extend
*/ */
public function extend(string $name, Closure $extend): void public function extend(string $name, Closure $extend): void
{ {

View File

@ -66,6 +66,6 @@ trait Pipeable
*/ */
private function pipes(string $name, object $context, string $scope): array private function pipes(string $name, object $context, string $scope): array
{ {
return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []); return array_map(fn (Closure $pipe): Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
} }
} }

View File

@ -7,13 +7,21 @@ namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Enums\ReplayType;
use Pest\Plugins\Tia\Recorder;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\Shell; use Pest\Support\Shell;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use ReflectionException; use ReflectionException;
use ReflectionFunction; use ReflectionFunction;
@ -73,6 +81,17 @@ trait Testable
*/ */
public bool $__ran = false; public bool $__ran = false;
/**
* The active replay mode for this test, set in `setUp()` and checked
* in `__runTest()` / `tearDown()` to skip the body and after-each.
*/
private ReplayType $__replay = ReplayType::None;
/**
* The cached assertion count to replay, captured when entering replay mode.
*/
private int $__replayAssertions = 0;
/** /**
* The test's test closure. * The test's test closure.
*/ */
@ -129,7 +148,7 @@ trait Testable
*/ */
public function __addBeforeAll(?Closure $hook): void public function __addBeforeAll(?Closure $hook): void
{ {
if (! $hook instanceof \Closure) { if (! $hook instanceof Closure) {
return; return;
} }
@ -143,7 +162,7 @@ trait Testable
*/ */
public function __addAfterAll(?Closure $hook): void public function __addAfterAll(?Closure $hook): void
{ {
if (! $hook instanceof \Closure) { if (! $hook instanceof Closure) {
return; return;
} }
@ -173,7 +192,7 @@ trait Testable
*/ */
private function __addHook(string $property, ?Closure $hook): void private function __addHook(string $property, ?Closure $hook): void
{ {
if (! $hook instanceof \Closure) { if (! $hook instanceof Closure) {
return; return;
} }
@ -257,8 +276,35 @@ trait Testable
self::$__latestIssues = $method->issues; self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs; self::$__latestPrs = $method->prs;
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$replay = ReplayType::fromStatus($status);
if ($replay !== ReplayType::None) {
assert($status !== null);
match ($replay) {
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
ReplayType::Skipped => $this->markTestSkipped($status->message()),
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
};
return;
}
$recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder);
if ($recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
parent::setUp(); parent::setUp();
Collectors::armAll($recorder);
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) { if ($this->__beforeEach instanceof Closure) {
@ -268,6 +314,13 @@ trait Testable
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
} }
private function __beginReplay(ReplayType $replay, Tia $tia): void
{
$this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__ran = true;
}
/** /**
* Initialize test case properties from TestSuite. * Initialize test case properties from TestSuite.
*/ */
@ -300,6 +353,12 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
if ($this->__replay !== ReplayType::None) {
TestSuite::getInstance()->test = null;
return;
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) { if ($this->__afterEach instanceof Closure) {
@ -325,10 +384,93 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
$this->expectNotToPerformAssertions();
}
$this->addToAssertionCount($this->__replayAssertions);
return null;
}
$arguments = $this->__resolveTestArguments($args); $arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
return $this->__callClosure($closure, $arguments); $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
if ($method->flakyTries === null) {
return $this->__callClosure($closure, $arguments);
}
$lastException = null;
$initialProperties = get_object_vars($this);
for ($attempt = 1; $attempt <= $method->flakyTries; $attempt++) {
try {
return $this->__callClosure($closure, $arguments);
} catch (Throwable $e) {
if ($e instanceof SkippedTest
|| $e instanceof IncompleteTest
|| $this->__isExpectedException($e)) {
throw $e;
}
$lastException = $e;
if ($attempt < $method->flakyTries) {
if ($this->__snapshotChanges !== []) {
throw $e;
}
$this->tearDown();
Closure::bind(fn (): array => $this->mockObjects = [], $this, TestCase::class)();
foreach (array_keys(array_diff_key(get_object_vars($this), $initialProperties)) as $property) {
unset($this->{$property});
}
$hasOutputExpectation = Closure::bind(fn (): bool => is_string($this->outputExpectedString) || is_string($this->outputExpectedRegex), $this, TestCase::class)();
if ($hasOutputExpectation) {
ob_clean();
}
$this->setUp();
}
}
}
throw $lastException;
}
/**
* Determines if the given exception matches PHPUnit's expected exception.
*/
private function __isExpectedException(Throwable $e): bool
{
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $this, TestCase::class)();
$expectedClass = $read('expectedException');
if ($expectedClass !== null) {
return $e instanceof $expectedClass;
}
$expectedMessage = $read('expectedExceptionMessage');
if ($expectedMessage !== null) {
return str_contains($e->getMessage(), (string) $expectedMessage);
}
$expectedCode = $read('expectedExceptionCode');
if ($expectedCode !== null) {
return $e->getCode() === $expectedCode;
}
return false;
} }
/** /**
@ -350,7 +492,8 @@ trait Testable
} }
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure'); $underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest)); $testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
$testParameterTypes = array_values($testParameterTypesByName);
if (count($arguments) !== 1) { if (count($arguments) !== 1) {
foreach ($arguments as $argumentIndex => $argumentValue) { foreach ($arguments as $argumentIndex => $argumentValue) {
@ -358,7 +501,11 @@ trait Testable
continue; continue;
} }
if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) { $parameterType = is_string($argumentIndex)
? $testParameterTypesByName[$argumentIndex]
: $testParameterTypes[$argumentIndex];
if (in_array($parameterType, [Closure::class, 'callable', 'mixed'])) {
continue; continue;
} }
@ -384,7 +531,7 @@ trait Testable
return [$boundDatasetResult]; return [$boundDatasetResult];
} }
return array_values($boundDatasetResult); return $boundDatasetResult;
} }
/** /**

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest; namespace Pest;
use Pest\PendingCalls\BeforeEachCall;
use Pest\PendingCalls\UsesCall; use Pest\PendingCalls\UsesCall;
/** /**
@ -32,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);
} }
/** /**
@ -59,7 +60,15 @@ 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);
}
/**
* Marks all tests in the current file to be run exclusively.
*/
public function only(): void
{
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
} }
/** /**
@ -110,6 +119,14 @@ final readonly class Configuration
return new Browser\Configuration; return new Browser\Configuration;
} }
/**
* Gets the TIA (Test Impact Analysis) configuration.
*/
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/** /**
* Proxies calls to the uses method. * Proxies calls to the uses method.
* *

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void;
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Pest\Support\View;
use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(
private readonly string $headline,
private readonly string $hint,
private readonly bool $hasAnchor = false,
) {
parent::__construct($headline);
}
public function render(OutputInterface $output): void
{
View::renderUsing($output);
if (! $this->hasAnchor) {
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
return;
}
$this->renderChild($output, $this->headline);
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
$output->writeln('');
}
public function exitCode(): int
{
return 1;
}
private function renderChild(OutputInterface $output, string $text): void
{
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
'',
]);
}
public function exitCode(): int
{
return 0;
}
}

View File

@ -0,0 +1,46 @@
<?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 TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
public function __construct(private readonly string $className, string $filename)
{
parent::__construct(sprintf(
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
$className,
$filename,
));
}
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
'',
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
sprintf(' in <fg=gray>%s</>.', $this->file),
'',
' Convert it to a Pest test, or run without Tia.',
'',
]);
}
public function exitCode(): int
{
return 1;
}
}

View File

@ -18,6 +18,7 @@ use Pest\Arch\Expectations\ToOnlyUse;
use Pest\Arch\Expectations\ToUse; use Pest\Arch\Expectations\ToUse;
use Pest\Arch\Expectations\ToUseNothing; use Pest\Arch\Expectations\ToUseNothing;
use Pest\Arch\PendingArchExpectation; use Pest\Arch\PendingArchExpectation;
use Pest\Arch\Support\Composer;
use Pest\Arch\Support\FileLineFinder; use Pest\Arch\Support\FileLineFinder;
use Pest\Concerns\Extendable; use Pest\Concerns\Extendable;
use Pest\Concerns\Pipeable; use Pest\Concerns\Pipeable;
@ -52,7 +53,9 @@ use ReflectionProperty;
*/ */
final class Expectation final class Expectation
{ {
/** @use Extendable<self<TValue>> */
use Extendable; use Extendable;
use Pipeable; use Pipeable;
use Retrievable; use Retrievable;
@ -134,7 +137,7 @@ final class Expectation
/** /**
* Dump the expectation value when the result of the condition is truthy. * Dump the expectation value when the result of the condition is truthy.
* *
* @param (\Closure(TValue): bool)|bool $condition * @param (Closure(TValue): bool)|bool $condition
* @return self<TValue> * @return self<TValue>
*/ */
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
@ -151,7 +154,7 @@ final class Expectation
/** /**
* Dump the expectation value when the result of the condition is falsy. * Dump the expectation value when the result of the condition is falsy.
* *
* @param (\Closure(TValue): bool)|bool $condition * @param (Closure(TValue): bool)|bool $condition
* @return self<TValue> * @return self<TValue>
*/ */
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
@ -235,7 +238,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;
@ -397,7 +400,7 @@ final class Expectation
* *
* @return Expectation<TValue>|OppositeExpectation<TValue>|EachExpectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue|null>|TValue * @return Expectation<TValue>|OppositeExpectation<TValue>|EachExpectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue|null>|TValue
*/ */
public function __get(string $name) public function __get(string $name): mixed
{ {
if (! self::hasMethod($name)) { if (! self::hasMethod($name)) {
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) { if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) {
@ -667,6 +670,41 @@ final class Expectation
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']); throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
} }
/**
* Asserts that the given expectation target is cased correctly.
*/
public function toBeCasedCorrectly(): ArchExpectation
{
return Targeted::make(
$this,
function (ObjectDescription $object): bool {
if (! isset($object->reflectionClass)) {
return false;
}
$realPath = realpath($object->path);
if ($realPath === false) {
return false;
}
foreach (Composer::allNamespacesWithDirectories() as $directory => $namespace) {
if (str_starts_with($realPath, $directory)) {
$relativePath = substr($realPath, strlen($directory) + 1);
$relativePath = explode('.', $relativePath)[0];
$classFromPath = $namespace.'\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
return $classFromPath === $object->reflectionClass->getName();
}
}
return false;
},
'to be cased correctly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/** /**
* Asserts that the given expectation target is enum. * Asserts that the given expectation target is enum.
*/ */
@ -781,7 +819,22 @@ final class Expectation
return false; return false;
} }
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) { $currentClass = $object->reflectionClass;
$usedTraits = [];
do {
$classTraits = $currentClass->getTraits();
foreach ($classTraits as $traitReflection) {
$usedTraits[$traitReflection->getName()] = $traitReflection->getName();
$nestedTraits = $traitReflection->getTraits();
foreach ($nestedTraits as $nestedTrait) {
$usedTraits[$nestedTrait->getName()] = $nestedTrait->getName();
}
}
} while ($currentClass = $currentClass->getParentClass());
if (! array_key_exists($trait, $usedTraits)) {
return false; return false;
} }
} }
@ -862,15 +915,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')),
); );
@ -1085,8 +1130,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')),
); );

View File

@ -15,6 +15,7 @@ use Pest\Arch\PendingArchExpectation;
use Pest\Arch\SingleArchExpectation; use Pest\Arch\SingleArchExpectation;
use Pest\Arch\Support\FileLineFinder; use Pest\Arch\Support\FileLineFinder;
use Pest\Exceptions\InvalidExpectation; use Pest\Exceptions\InvalidExpectation;
use Pest\Exceptions\MissingDependency;
use Pest\Expectation; use Pest\Expectation;
use Pest\Support\Arr; use Pest\Support\Arr;
use Pest\Support\Exporter; use Pest\Support\Exporter;
@ -284,6 +285,10 @@ final readonly class OppositeExpectation
*/ */
public function toHaveSuspiciousCharacters(): ArchExpectation public function toHaveSuspiciousCharacters(): ArchExpectation
{ {
if (! class_exists(Spoofchecker::class)) {
throw new MissingDependency(__FUNCTION__, 'ext-intl >= 2.0');
}
$checker = new Spoofchecker; $checker = new Spoofchecker;
/** @var Expectation<array<int, string>|string> $original */ /** @var Expectation<array<int, string>|string> $original */
@ -571,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')),
); );
@ -599,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')),
); );
@ -809,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)),
)); ));
} }
@ -847,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')),
); );

View File

@ -17,6 +17,7 @@ use Pest\Factories\Concerns\HigherOrderable;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\Str; use Pest\Support\Str;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use RuntimeException; use RuntimeException;
@ -58,6 +59,11 @@ final class TestCaseFactory
Concerns\Expectable::class, Concerns\Expectable::class,
]; ];
/**
* The namespace for the test case, overrides the path-based namespace when set.
*/
public ?string $namespace = null;
/** /**
* Creates a new Factory instance. * Creates a new Factory instance.
*/ */
@ -110,8 +116,8 @@ final class TestCaseFactory
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath); $relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
// Remove escaped quote sequences (maintain namespace) // Remove escaped quote sequences (maintain namespace)
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath); $relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
// Limit to A-Z, a-z, 0-9, '_', '-'. // Limit to Unicode letters and numbers.
$relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath); $relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath);
$classFQN = 'P\\'.$relativePath; $classFQN = 'P\\'.$relativePath;
@ -126,7 +132,7 @@ final class TestCaseFactory
$partsFQN = explode('\\', $classFQN); $partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN); $className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN); $namespace = $this->namespace ?? implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class); $baseClass = sprintf('\%s', $this->class);
if (trim($className) === '') { if (trim($className) === '') {
@ -135,7 +141,7 @@ final class TestCaseFactory
$this->attributes = [ $this->attributes = [
new Attribute( new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class, TestDox::class,
[$this->filename], [$this->filename],
), ),
...$this->attributes, ...$this->attributes,
@ -160,7 +166,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode $traitsCode
private static \$__filename = '$filename'; public static \$__filename = '$filename';
$methodsCode $methodsCode
} }
@ -191,7 +197,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);

View File

@ -9,10 +9,14 @@ use Pest\Evaluators\Attributes;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable; use Pest\Factories\Concerns\HigherOrderable;
use Pest\Repositories\DatasetsRepository; use Pest\Repositories\DatasetsRepository;
use Pest\Support\Description;
use Pest\Support\Str; use Pest\Support\Str;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Assert; use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@ -32,7 +36,7 @@ final class TestCaseMethodFactory
/** /**
* The test's describing, if any. * The test's describing, if any.
* *
* @var array<int, \Pest\Support\Description> * @var array<int, Description>
*/ */
public array $describing = []; public array $describing = [];
@ -46,6 +50,11 @@ final class TestCaseMethodFactory
*/ */
public int $repetitions = 1; public int $repetitions = 1;
/**
* The test's number of flaky retry tries.
*/
public ?int $flakyTries = null;
/** /**
* Determines if the test is a "todo". * Determines if the test is a "todo".
*/ */
@ -192,11 +201,11 @@ final class TestCaseMethodFactory
$this->attributes = [ $this->attributes = [
new Attribute( new Attribute(
\PHPUnit\Framework\Attributes\Test::class, Test::class,
[], [],
), ),
new Attribute( new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class, TestDox::class,
[str_replace('*/', '{@*}', $this->description)], [str_replace('*/', '{@*}', $this->description)],
), ),
...$this->attributes, ...$this->attributes,
@ -206,7 +215,7 @@ final class TestCaseMethodFactory
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend)); $depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
$this->attributes[] = new Attribute( $this->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Depends::class, Depends::class,
[$depend], [$depend],
); );
} }

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
use Pest\Browser\Api\ArrayablePendingAwaitablePage; use Pest\Browser\Api\ArrayablePendingAwaitablePage;
use Pest\Browser\Api\PendingAwaitablePage; use Pest\Browser\Api\PendingAwaitablePage;
use Pest\Concerns\Expectable;
use Pest\Configuration; use Pest\Configuration;
use Pest\Exceptions\AfterAllWithinDescribe; use Pest\Exceptions\AfterAllWithinDescribe;
use Pest\Exceptions\BeforeAllWithinDescribe; use Pest\Exceptions\BeforeAllWithinDescribe;
@ -48,7 +47,7 @@ if (! function_exists('beforeAll')) {
function beforeAll(Closure $closure): void function beforeAll(Closure $closure): void
{ {
if (DescribeCall::describing() !== []) { if (DescribeCall::describing() !== []) {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
throw new BeforeAllWithinDescribe($filename); throw new BeforeAllWithinDescribe($filename);
} }
@ -61,13 +60,11 @@ if (! function_exists('beforeEach')) {
/** /**
* Runs the given closure before each test in the current file. * Runs the given closure before each test in the current file.
* *
* @param-closure-this TestCase $closure * @param-closure-this TestCall $closure
*
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
*/ */
function beforeEach(?Closure $closure = null): BeforeEachCall function beforeEach(?Closure $closure = null): BeforeEachCall
{ {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure); return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
} }
@ -92,8 +89,6 @@ if (! function_exists('describe')) {
* Adds the given closure as a group of tests. The first argument * Adds the given closure as a group of tests. The first argument
* is the group description; the second argument is a closure * is the group description; the second argument is a closure
* that contains the group tests. * that contains the group tests.
*
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
*/ */
function describe(string $description, Closure $tests): DescribeCall function describe(string $description, Closure $tests): DescribeCall
{ {
@ -112,7 +107,7 @@ if (! function_exists('uses')) {
*/ */
function uses(string ...$classAndTraits): UsesCall function uses(string ...$classAndTraits): UsesCall
{ {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
return new UsesCall($filename, array_values($classAndTraits)); return new UsesCall($filename, array_values($classAndTraits));
} }
@ -124,7 +119,7 @@ if (! function_exists('pest')) {
*/ */
function pest(): Configuration function pest(): Configuration
{ {
return new Configuration(Backtrace::file()); return new Configuration(Backtrace::testFile());
} }
} }
@ -134,13 +129,13 @@ if (! function_exists('test')) {
* is the test description; the second argument is * is the test description; the second argument is
* a closure that contains the test expectations. * a closure that contains the test expectations.
* *
* @param-closure-this TestCase $closure * @param-closure-this TestCall $closure
* *
* @return Expectable|TestCall|TestCase|mixed * @return ($description is string ? TestCall : HigherOrderTapProxy|TestCall)
*/ */
function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall
{ {
if ($description === null && TestSuite::getInstance()->test instanceof \PHPUnit\Framework\TestCase) { if ($description === null && TestSuite::getInstance()->test instanceof TestCase) {
return new HigherOrderTapProxy(TestSuite::getInstance()->test); return new HigherOrderTapProxy(TestSuite::getInstance()->test);
} }
@ -156,34 +151,23 @@ if (! function_exists('it')) {
* is the test description; the second argument is * is the test description; the second argument is
* a closure that contains the test expectations. * a closure that contains the test expectations.
* *
* @param-closure-this TestCase $closure * @param-closure-this TestCall $closure
*
* @return Expectable|TestCall|TestCase|mixed
*/ */
function it(string $description, ?Closure $closure = null): TestCall function it(string $description, ?Closure $closure = null): TestCall
{ {
$description = sprintf('it %s', $description); $description = sprintf('it %s', $description);
/** @var TestCall $test */ return test($description, $closure);
$test = test($description, $closure);
return $test;
} }
} }
if (! function_exists('todo')) { if (! function_exists('todo')) {
/** /**
* Creates a new test that is marked as "todo". * Creates a new test that is marked as "todo".
*
* @return Expectable|TestCall|TestCase|mixed
*/ */
function todo(string $description): TestCall function todo(string $description): TestCall
{ {
$test = test($description); return test($description)->todo();
assert($test instanceof TestCall);
return $test->todo();
} }
} }
@ -191,13 +175,11 @@ if (! function_exists('afterEach')) {
/** /**
* Runs the given closure after each test in the current file. * Runs the given closure after each test in the current file.
* *
* @param-closure-this TestCase $closure * @param-closure-this TestCall $closure
*
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|TestCall|mixed
*/ */
function afterEach(?Closure $closure = null): AfterEachCall function afterEach(?Closure $closure = null): AfterEachCall
{ {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure); return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
} }
@ -210,7 +192,7 @@ if (! function_exists('afterAll')) {
function afterAll(Closure $closure): void function afterAll(Closure $closure): void
{ {
if (DescribeCall::describing() !== []) { if (DescribeCall::describing() !== []) {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
throw new AfterAllWithinDescribe($filename); throw new AfterAllWithinDescribe($filename);
} }
@ -227,7 +209,7 @@ if (! function_exists('covers')) {
*/ */
function covers(array|string ...$classesOrFunctions): void function covers(array|string ...$classesOrFunctions): void
{ {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename)); $beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
@ -236,7 +218,7 @@ if (! function_exists('covers')) {
/** @var MutationTestRunner $runner */ /** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class); $runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */ /** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class); $configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false; $everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false; $classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
@ -256,14 +238,14 @@ if (! function_exists('mutates')) {
*/ */
function mutates(array|string ...$targets): void function mutates(array|string ...$targets): void
{ {
$filename = Backtrace::file(); $filename = Backtrace::testFile();
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename)); $beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
$beforeEachCall->group('__pest_mutate_only'); $beforeEachCall->group('__pest_mutate_only');
/** @var MutationTestRunner $runner */ /** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class); $runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */ /** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class); $configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false; $everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false; $classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
@ -320,7 +302,7 @@ if (! function_exists('visit')) {
*/ */
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
{ {
if (! class_exists(\Pest\Browser\Configuration::class)) { if (! class_exists(Pest\Browser\Configuration::class)) {
PluginBrowser::install(); PluginBrowser::install();
exit(0); exit(0);

View File

@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/** /**
* @internal * @internal
*/ */
final readonly class Kernel final class Kernel
{ {
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/** /**
* The Kernel bootstrappers. * The Kernel bootstrappers.
* *
@ -36,6 +41,8 @@ final readonly class Kernel
*/ */
private const array BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class, Bootstrappers\BootView::class,
@ -43,15 +50,22 @@ final readonly class Kernel
Bootstrappers\BootExcludeList::class, Bootstrappers\BootExcludeList::class,
]; ];
/**
* The Kernel restarters — resolved and invoked from `bin/pest`
* before any other Pest class is touched, so the list is exposed
* on the Kernel rather than driven from `bin/pest` directly.
*
* @var array<int, class-string<Contracts\Restarter>>
*/
public const array RESTARTERS = [
Restarters\XdebugRestarter::class,
Restarters\PcovRestarter::class,
];
/** /**
* Creates a new Kernel instance. * Creates a new Kernel instance.
*/ */
public function __construct( public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
private Application $application,
private OutputInterface $output,
) {
//
}
/** /**
* Boots the Kernel. * Boots the Kernel.
@ -71,7 +85,7 @@ final readonly class Kernel
$output, $output,
); );
register_shutdown_function(fn () => $kernel->shutdown()); register_shutdown_function($kernel->shutdown(...));
foreach (self::BOOTSTRAPPERS as $bootstrapper) { foreach (self::BOOTSTRAPPERS as $bootstrapper) {
$bootstrapper = Container::getInstance()->get($bootstrapper); $bootstrapper = Container::getInstance()->get($bootstrapper);
@ -112,9 +126,13 @@ final readonly class Kernel
$configuration = Registry::get(); $configuration = Registry::get();
$result = Facade::result(); $result = Facade::result();
return CallsAddsOutput::execute( $result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result), Result::exitCode($configuration, $result),
); );
$this->terminate();
return $result;
} }
/** /**
@ -122,6 +140,12 @@ final readonly class Kernel
*/ */
public function terminate(): void public function terminate(): void
{ {
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class); $preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump); assert($preBufferOutput instanceof KernelDump);
@ -139,7 +163,7 @@ final readonly 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;
} }

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');
} }
} }

View File

@ -12,7 +12,9 @@ use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored; use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky; use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed; use PHPUnit\Event\Test\Failed;
@ -131,7 +133,7 @@ final readonly class Converter
// clean the paths of each frame. // clean the paths of each frame.
$frames = array_map( $frames = array_map(
fn (string $frame): string => $this->toRelativePath($frame), $this->toRelativePath(...),
$frames $frames
); );
@ -151,7 +153,7 @@ final readonly class Converter
{ {
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) { if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$firstTest = $this->getFirstTest($testSuite); $firstTest = $this->getFirstTest($testSuite);
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) { if ($firstTest instanceof TestMethod) {
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest); return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
} }
} }
@ -179,7 +181,7 @@ final readonly class Converter
public function getTestSuiteLocation(TestSuite $testSuite): ?string public function getTestSuiteLocation(TestSuite $testSuite): ?string
{ {
$firstTest = $this->getFirstTest($testSuite); $firstTest = $this->getFirstTest($testSuite);
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) { if (! $firstTest instanceof TestMethod) {
return null; return null;
} }
$path = $firstTest->testDox()->prettifiedClassName(); $path = $firstTest->testDox()->prettifiedClassName();
@ -255,9 +257,11 @@ final readonly class Converter
$numberOfNotPassedTests = count( $numberOfNotPassedTests = count(
array_unique( array_unique(
array_map( 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 if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) { || $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName(); return $event->testClassName();
} }

View File

@ -200,7 +200,7 @@ final class TeamCityLogger
public function testFinished(Finished $event): void public function testFinished(Finished $event): void
{ {
if (! $this->time instanceof \PHPUnit\Event\Telemetry\HRTime) { if (! $this->time instanceof HRTime) {
throw ShouldNotHappen::fromMessage('Start time has not been set.'); throw ShouldNotHappen::fromMessage('Start time has not been set.');
} }

View File

@ -9,10 +9,12 @@ use Closure;
use Countable; use Countable;
use DateTimeInterface; use DateTimeInterface;
use Error; use Error;
use Illuminate\Testing\TestResponse;
use InvalidArgumentException; use InvalidArgumentException;
use JsonSerializable; use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue; use Pest\Exceptions\InvalidExpectationValue;
use Pest\Matchers\Any; use Pest\Matchers\Any;
use Pest\Plugins\Snapshot;
use Pest\Support\Arr; use Pest\Support\Arr;
use Pest\Support\Exporter; use Pest\Support\Exporter;
use Pest\Support\NullClosure; use Pest\Support\NullClosure;
@ -842,7 +844,7 @@ final class Expectation
is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(), is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(),
is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(), is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(),
is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(), is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(),
$this->value instanceof \Illuminate\Testing\TestResponse => $this->value->getContent(), // @phpstan-ignore-line $this->value instanceof TestResponse => $this->value->getContent(), // @phpstan-ignore-line
is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), $this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT), $this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
@ -850,18 +852,31 @@ final class Expectation
default => InvalidExpectationValue::expected('array|object|string'), default => InvalidExpectationValue::expected('array|object|string'),
}; };
if ($snapshots->has()) { 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 {
$filename = $snapshots->save($string); $filename = $snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]"); 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; return $this;
@ -921,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.');
@ -939,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);
@ -983,7 +999,7 @@ final class Expectation
*/ */
private function export(mixed $value): string private function export(mixed $value): string
{ {
if (! $this->exporter instanceof \Pest\Support\Exporter) { if (! $this->exporter instanceof Exporter) {
$this->exporter = Exporter::default(); $this->exporter = Exporter::default();
} }

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Pest\PHPStan;
use Pest\Expectations\HigherOrderExpectation;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\ExpressionTypeResolverExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
/**
* Prevents native declared properties of HigherOrderExpectation (like $original,
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
*
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
*
* @internal
*/
final readonly class HigherOrderExpectationTypeExtension implements ExpressionTypeResolverExtension
{
public function __construct(
private ReflectionProvider $reflectionProvider,
) {}
public function getType(Expr $expr, Scope $scope): ?Type
{
if (! $expr instanceof PropertyFetch || ! $expr->name instanceof Identifier) {
return null;
}
$varType = $scope->getType($expr->var);
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
return null;
}
if (! $this->reflectionProvider->hasClass(HigherOrderExpectation::class)) {
return null;
}
$propertyName = $expr->name->name;
$classReflection = $this->reflectionProvider->getClass(HigherOrderExpectation::class);
if (! $classReflection->hasNativeProperty($propertyName)) {
return null;
}
return $varType->getProperty($propertyName, $scope)->getReadableType();
}
}

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Pest\PendingCalls\Concerns; namespace Pest\PendingCalls\Concerns;
use Pest\Support\Description;
/** /**
* @internal * @internal
*/ */
@ -12,14 +14,14 @@ trait Describable
/** /**
* Note: this is property is not used; however, it gets added automatically by rector php. * Note: this is property is not used; however, it gets added automatically by rector php.
* *
* @var array<int, \Pest\Support\Description> * @var array<int, Description>
*/ */
public array $__describing; public array $__describing;
/** /**
* The describing of the test case. * The describing of the test case.
* *
* @var array<int, \Pest\Support\Description> * @var array<int, Description>
*/ */
public array $describing = []; public array $describing = [];
} }

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\PendingCalls; namespace Pest\PendingCalls;
use Closure; use Closure;
use Pest\Support\Backtrace;
use Pest\Support\Description; use Pest\Support\Description;
use Pest\TestSuite; use Pest\TestSuite;
@ -53,7 +52,11 @@ final class DescribeCall
*/ */
public function __destruct() public function __destruct()
{ {
unset($this->currentBeforeEachCall); // Ensure BeforeEachCall destructs before creating tests
// by moving to local scope and clearing the reference
$beforeEach = $this->currentBeforeEachCall;
$this->currentBeforeEachCall = null;
unset($beforeEach); // Trigger destructor immediately
self::$describing[] = $this->description; self::$describing[] = $this->description;
@ -71,12 +74,13 @@ final class DescribeCall
*/ */
public function __call(string $name, array $arguments): self public function __call(string $name, array $arguments): self
{ {
$filename = Backtrace::file(); if (! $this->currentBeforeEachCall instanceof BeforeEachCall) {
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $this->filename);
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) { $this->currentBeforeEachCall->describing = array_merge(
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename); DescribeCall::describing(),
[$this->description]
$this->currentBeforeEachCall->describing[] = $this->description; );
} }
$this->currentBeforeEachCall->{$name}(...$arguments); $this->currentBeforeEachCall->{$name}(...$arguments);

View File

@ -22,6 +22,10 @@ use Pest\Support\NullClosure;
use Pest\Support\Str; use Pest\Support\Str;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@ -211,7 +215,7 @@ final class TestCall // @phpstan-ignore-line
{ {
foreach ($groups as $group) { foreach ($groups as $group) {
$this->testCaseMethod->attributes[] = new Attribute( $this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Group::class, Group::class,
[$group], [$group],
); );
} }
@ -408,6 +412,20 @@ final class TestCall // @phpstan-ignore-line
return $this; return $this;
} }
/**
* Marks the test as flaky, retrying it up to the given number of times.
*/
public function flaky(int $tries = 3): self
{
if ($tries < 1) {
throw new InvalidArgumentException('The number of tries must be greater than 0.');
}
$this->testCaseMethod->flakyTries = $tries;
return $this;
}
/** /**
* Marks the test as "todo". * Marks the test as "todo".
*/ */
@ -604,7 +622,7 @@ final class TestCall // @phpstan-ignore-line
{ {
foreach ($classes as $class) { foreach ($classes as $class) {
$this->testCaseFactoryAttributes[] = new Attribute( $this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversClass::class, CoversClass::class,
[$class], [$class],
); );
} }
@ -627,7 +645,7 @@ final class TestCall // @phpstan-ignore-line
{ {
foreach ($traits as $trait) { foreach ($traits as $trait) {
$this->testCaseFactoryAttributes[] = new Attribute( $this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversTrait::class, CoversTrait::class,
[$trait], [$trait],
); );
} }
@ -650,7 +668,7 @@ final class TestCall // @phpstan-ignore-line
{ {
foreach ($functions as $function) { foreach ($functions as $function) {
$this->testCaseFactoryAttributes[] = new Attribute( $this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversFunction::class, CoversFunction::class,
[$function], [$function],
); );
} }

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);

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string function version(): string
{ {
return '4.1.2'; return '5.0.0-rc.9';
} }
function testDirectory(string $file = ''): string function testDirectory(string $file = ''): string

View File

@ -56,4 +56,31 @@ trait HandleArguments
return array_values(array_flip($arguments)); return array_values(array_flip($arguments));
} }
/**
* Pops the given argument and its value from the arguments, returning the value.
*
* @param array<int, string> $arguments
*/
public function popArgumentValue(string $argument, array &$arguments): ?string
{
foreach ($arguments as $key => $value) {
if (str_contains($value, "$argument=")) {
unset($arguments[$key]);
$arguments = array_values($arguments);
return substr($value, strlen($argument) + 1);
}
if ($value === $argument && isset($arguments[$key + 1])) {
$result = $arguments[$key + 1];
unset($arguments[$key], $arguments[$key + 1]);
$arguments = array_values($arguments);
return $result;
}
}
return null;
}
} }

View File

@ -23,6 +23,8 @@ final class Coverage implements AddsOutput, HandlesArguments
private const string EXACTLY_OPTION = 'exactly'; private const string EXACTLY_OPTION = 'exactly';
private const string ONLY_COVERED_OPTION = 'only-covered';
/** /**
* Whether it should show the coverage or not. * Whether it should show the coverage or not.
*/ */
@ -43,6 +45,11 @@ final class Coverage implements AddsOutput, HandlesArguments
*/ */
public ?float $coverageExactly = null; public ?float $coverageExactly = null;
/**
* Whether it should show only covered files.
*/
public bool $showOnlyCovered = false;
/** /**
* Creates a new Plugin instance. * Creates a new Plugin instance.
*/ */
@ -57,7 +64,7 @@ final class Coverage implements AddsOutput, HandlesArguments
public function handleArguments(array $originals): array public function handleArguments(array $originals): array
{ {
$arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool {
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION] as $option) { foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) {
if ($original === sprintf('--%s', $option)) { if ($original === sprintf('--%s', $option)) {
return true; return true;
} }
@ -80,6 +87,7 @@ final class Coverage implements AddsOutput, HandlesArguments
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE); $inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED); $inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
$inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED); $inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED);
$inputs[] = new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE);
$input = new ArgvInput($arguments, new InputDefinition($inputs)); $input = new ArgvInput($arguments, new InputDefinition($inputs));
if ((bool) $input->getOption(self::COVERAGE_OPTION)) { if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
@ -120,6 +128,10 @@ final class Coverage implements AddsOutput, HandlesArguments
$this->coverageExactly = (float) $exactlyOption; $this->coverageExactly = (float) $exactlyOption;
} }
if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) {
$this->showOnlyCovered = true;
}
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) { if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
$this->compact = true; $this->compact = true;
} }
@ -144,7 +156,7 @@ final class Coverage implements AddsOutput, HandlesArguments
exit(1); exit(1);
} }
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact); $coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered);
$exitCode = (int) ($coverage < $this->coverageMin); $exitCode = (int) ($coverage < $this->coverageMin);
if ($exitCode === 0 && $this->coverageExactly !== null) { if ($exitCode === 0 && $this->coverageExactly !== null) {

View File

@ -99,6 +99,7 @@ final readonly class Help implements HandlesArguments
{ {
$helpReflection = new PHPUnitHelp; $helpReflection = new PHPUnitHelp;
// @phpstan-ignore-next-line
$content = (fn (): array => $this->elements())->call($helpReflection); $content = (fn (): array => $this->elements())->call($helpReflection);
$content['Configuration'] = [...[[ $content['Configuration'] = [...[[
@ -106,6 +107,13 @@ final readonly class Help implements HandlesArguments
'desc' => 'Initialise a standard Pest configuration', 'desc' => 'Initialise a standard Pest configuration',
]], ...$content['Configuration']]; ]], ...$content['Configuration']];
$content['AI'] = [
[
'arg' => '--ai',
'desc' => 'Run a code snippet as a fully scaffolded test for AI verification',
],
];
$content['Execution'] = [...[ $content['Execution'] = [...[
[ [
'arg' => '--parallel', 'arg' => '--parallel',
@ -115,6 +123,10 @@ final readonly class Help implements HandlesArguments
'arg' => '--update-snapshots', 'arg' => '--update-snapshots',
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation', '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['Execution']];
$content['Selection'] = [[ $content['Selection'] = [[
@ -141,6 +153,12 @@ final readonly class Help implements HandlesArguments
], [ ], [
'arg' => '--retry', 'arg' => '--retry',
'desc' => 'Run non-passing tests first and stop execution upon first error or failure', 'desc' => 'Run non-passing tests first and stop execution upon first error or failure',
], [
'arg' => '--dirty',
'desc' => 'Only run tests that have uncommitted changes according to Git',
], [
'arg' => '--flaky',
'desc' => 'Output to standard output tests marked as flaky',
], ...$content['Selection']]; ], ...$content['Selection']];
$content['Reporting'] = [...$content['Reporting'], ...[ $content['Reporting'] = [...$content['Reporting'], ...[
@ -156,6 +174,12 @@ final readonly class Help implements HandlesArguments
], [ ], [
'arg' => '--coverage --min', 'arg' => '--coverage --min',
'desc' => 'Set the minimum required coverage percentage, and fail if not met', 'desc' => 'Set the minimum required coverage percentage, and fail if not met',
], [
'arg' => '--coverage --exactly',
'desc' => 'Set the exact required coverage percentage, and fail if not met',
], [
'arg' => '--coverage --only-covered',
'desc' => 'Hide files with 0% coverage from the code coverage report',
], ...$content['Code Coverage']]; ], ...$content['Code Coverage']];
$content['Mutation Testing'] = [[ $content['Mutation Testing'] = [[

View File

@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments
/** /**
* @var string[] * @var string[]
*/ */
private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request']; private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request', '--flaky'];
/** /**
* Whether the given command line arguments indicate that the test suite should be run in parallel. * Whether the given command line arguments indicate that the test suite should be run in parallel.
@ -127,7 +127,9 @@ final class Parallel implements HandlesArguments
$arguments $arguments
); );
$exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput); $filteredArguments = $this->processTeamcityArguments($filteredArguments);
$exitCode = $this->paratestCommand()->run(new ArgvInput(array_values($filteredArguments)), new CleanConsoleOutput);
return CallsAddsOutput::execute($exitCode); return CallsAddsOutput::execute($exitCode);
} }
@ -176,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;
} }
/** /**
@ -197,4 +193,18 @@ final class Parallel implements HandlesArguments
return $this->popArgument('-p', $arguments); return $this->popArgument('-p', $arguments);
} }
/**
* @param string[] $arguments
* @return string[]
*/
public function processTeamcityArguments(array $arguments): array
{
$argv = new ArgvInput;
if ($argv->hasParameterOption('--teamcity')) {
$arguments[] = '--teamcity';
}
return $arguments;
}
} }

View File

@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Handlers;
use Closure; use Closure;
use Composer\InstalledVersions; use Composer\InstalledVersions;
use Illuminate\Testing\ParallelRunner; use Illuminate\Testing\ParallelRunner;
use Orchestra\Testbench\TestCase;
use ParaTest\Options; use ParaTest\Options;
use ParaTest\RunnerInterface; use ParaTest\RunnerInterface;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
@ -39,13 +40,13 @@ final class Laravel implements HandlesArguments
* Executes the given closure when running Laravel. * Executes the given closure when running Laravel.
* *
* @param array<int, string> $arguments * @param array<int, string> $arguments
* @param CLosure(array<int, string>): array<int, string> $closure * @param Closure(array<int, string>): array<int, string> $closure
* @return array<int, string> * @return array<int, string>
*/ */
private function whenUsingLaravel(array $arguments, Closure $closure): array private function whenUsingLaravel(array $arguments, Closure $closure): array
{ {
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false); $isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
$isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class); $isLaravelPackage = class_exists(TestCase::class);
if ($isLaravelApplication && ! $isLaravelPackage) { if ($isLaravelApplication && ! $isLaravelPackage) {
return $closure($arguments); return $closure($arguments);

View File

@ -81,7 +81,9 @@ final class ResultPrinter
public function flush(): void {} public function flush(): void {}
}; };
$this->compactPrinter = CompactPrinter::default(); $this->compactPrinter = CompactPrinter::default(
decorated: ! in_array('--colors=never', $_SERVER['argv'] ?? [], true),
);
if (! $this->options->configuration->hasLogfileTeamcity()) { if (! $this->options->configuration->hasLogfileTeamcity()) {
return; return;
@ -92,14 +94,13 @@ final class ResultPrinter
$this->teamcityLogFileHandle = $teamcityLogFileHandle; $this->teamcityLogFileHandle = $teamcityLogFileHandle;
} }
/** @param list<SplFileInfo> $teamcityFiles */
public function printFeedback( public function printFeedback(
SplFileInfo $progressFile, SplFileInfo $progressFile,
SplFileInfo $outputFile, SplFileInfo $outputFile,
array $teamcityFiles ?SplFileInfo $teamcityFile,
): void { ): void {
if ($this->options->needsTeamcity) { if ($this->options->needsTeamcity && $teamcityFile instanceof SplFileInfo) {
$teamcityProgress = $this->tailMultiple($teamcityFiles); $teamcityProgress = $this->tailMultiple([$teamcityFile]);
if ($this->teamcityLogFileHandle !== null) { if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityProgress); fwrite($this->teamcityLogFileHandle, $teamcityProgress);
@ -171,8 +172,18 @@ final class ResultPrinter
$state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult); $state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult);
$this->compactPrinter->errors($state); if ($testResult->numberOfTestsRun() === 0 && $state->testSuiteTestsCount() === 0) {
$this->compactPrinter->recap($state, $testResult, $duration, $this->options); $this->output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
'',
]);
}
if (! isset($_SERVER['PEST_PARALLEL_NO_OUTPUT'])) {
$this->compactPrinter->errors($state);
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
}
} }
private function printFeedbackItem(string $item): void private function printFeedbackItem(string $item): void

View File

@ -7,7 +7,6 @@ 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;
@ -25,11 +24,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;
@ -39,6 +44,7 @@ use function dirname;
use function file_get_contents; use function file_get_contents;
use function max; use function max;
use function realpath; use function realpath;
use function str_starts_with;
use function unlink; use function unlink;
use function unserialize; use function unserialize;
use function usleep; use function usleep;
@ -51,6 +57,11 @@ final class WrapperRunner implements RunnerInterface
/** /**
* The time to sleep between cycles. * The time to sleep between cycles.
*/ */
/**
* The merged test result from the parallel run.
*/
public static ?TestResult $result = null;
private const int CYCLE_SLEEP = 10000; private const int CYCLE_SLEEP = 10000;
/** /**
@ -131,6 +142,7 @@ final class WrapperRunner implements RunnerInterface
$parameters = $this->handleLaravelHerd($parameters); $parameters = $this->handleLaravelHerd($parameters);
$parameters[] = $wrapper; $parameters[] = $wrapper;
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
$this->parameters = $parameters; $this->parameters = $parameters;
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry; $this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry;
@ -139,7 +151,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();
@ -225,7 +236,7 @@ final class WrapperRunner implements RunnerInterface
$this->printer->printFeedback( $this->printer->printFeedback(
$worker->progressFile, $worker->progressFile,
$worker->unexpectedOutputFile, $worker->unexpectedOutputFile,
$this->teamcityFiles, $worker->teamcityFile ?? null,
); );
$worker->reset(); $worker->reset();
} }
@ -385,6 +396,8 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->numberOfIssuesIgnoredByBaseline(), $testResultSum->numberOfIssuesIgnoredByBaseline(),
); );
self::$result = $testResultSum;
if ($this->options->configuration->cacheResult()) { if ($this->options->configuration->cacheResult()) {
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile()); $resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
foreach ($this->resultCacheFiles as $resultCacheFile) { foreach ($this->resultCacheFiles as $resultCacheFile) {
@ -439,10 +452,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,
@ -483,15 +519,61 @@ final class WrapperRunner implements RunnerInterface
*/ */
private function getTestFiles(SuiteLoader $suiteLoader): array private function getTestFiles(SuiteLoader $suiteLoader): array
{ {
/** @var array<string, non-empty-string> $files */ /** @var array<string, null> $files */
$files = [ $files = [];
...array_values(array_filter(
$suiteLoader->tests,
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
)),
...TestSuite::getInstance()->tests->getFilenames(),
];
return $files; // @phpstan-ignore-line foreach (array_filter(
$suiteLoader->tests,
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
) as $filename) {
$resolved = realpath($filename) ?: $filename;
$files[$resolved] = null;
}
foreach (TestSuite::getInstance()->tests->getFilenames() as $filename) {
if ($this->shouldIncludeBootstrappedTestFile($filename)) {
$resolved = realpath($filename)
?: realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename)
?: $filename;
$files[$resolved] = null;
}
}
return array_keys($files); // @phpstan-ignore-line
}
private function shouldIncludeBootstrappedTestFile(string $filename): bool
{
if (! $this->options->configuration->hasCliArguments()) {
return true;
}
$resolvedFilename = realpath($filename);
if ($resolvedFilename === false) {
$resolvedFilename = realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename);
}
if ($resolvedFilename === false) {
return false;
}
foreach ($this->options->configuration->cliArguments() as $path) {
$resolvedPath = realpath($path);
if ($resolvedPath === false) {
continue;
}
if ($resolvedFilename === $resolvedPath) {
return true;
}
if (is_dir($resolvedPath) && str_starts_with($resolvedFilename, $resolvedPath.DIRECTORY_SEPARATOR)) {
return true;
}
}
return false;
} }
} }

View File

@ -62,12 +62,12 @@ final class CompactPrinter
/** /**
* Creates a new instance of the Compact Printer. * Creates a new instance of the Compact Printer.
*/ */
public static function default(): self public static function default(bool $decorated = true): self
{ {
return new self( return new self(
terminal(), terminal(),
new ConsoleOutput(decorated: true), new ConsoleOutput(decorated: $decorated),
new Style(new ConsoleOutput(decorated: true)), new Style(new ConsoleOutput(decorated: $decorated)),
terminal()->width() - 4, terminal()->width() - 4,
); );
} }

View File

@ -6,7 +6,13 @@ namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption; 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\ArgvInput;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -15,7 +21,7 @@ use Symfony\Component\Process\Process;
/** /**
* @internal * @internal
*/ */
final class Shard implements AddsOutput, HandlesArguments final class Shard implements AddsOutput, HandlesArguments, Terminable
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
@ -33,6 +39,40 @@ final class Shard implements AddsOutput, HandlesArguments
*/ */
private static ?array $shard = null; 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. * Creates a new Plugin instance.
*/ */
@ -47,6 +87,19 @@ final class Shard implements AddsOutput, HandlesArguments
*/ */
public function handleArguments(array $arguments): array 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)) { if (! $this->hasArgument('--shard', $arguments)) {
return $arguments; return $arguments;
} }
@ -63,7 +116,24 @@ final class Shard implements AddsOutput, HandlesArguments
/** @phpstan-ignore-next-line */ /** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments); $tests = $this->allTests($arguments);
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
$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 = [ self::$shard = [
'index' => $index, 'index' => $index,
@ -72,9 +142,43 @@ final class Shard implements AddsOutput, HandlesArguments
'testsCount' => count($tests), 'testsCount' => count($tests),
]; ];
if ($testsToRun === []) {
return $arguments;
}
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)]; 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. * Returns all tests that the test suite would run.
* *
@ -83,11 +187,11 @@ final class Shard implements AddsOutput, HandlesArguments
*/ */
private function allTests(array $arguments): array private function allTests(array $arguments): array
{ {
$output = (new Process([ $output = new Process([
'php', 'php',
...$this->removeParallelArguments($arguments), ...$this->removeParallelArguments($arguments),
'--list-tests', '--list-tests',
]))->mustRun()->getOutput(); ])->setTimeout(120)->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches); preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
@ -116,6 +220,22 @@ final class Shard implements AddsOutput, HandlesArguments
*/ */
public function addOutput(int $exitCode): int 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) { if (self::$shard === null) {
return $exitCode; return $exitCode;
} }
@ -128,17 +248,250 @@ final class Shard implements AddsOutput, HandlesArguments
] = self::$shard; ] = self::$shard;
$this->output->writeln(sprintf( $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, $index,
$total, $total,
$testsRan, $testsRan,
$testsRan === 1 ? '' : 's', $testsRan === 1 ? '' : 's',
$testsCount, $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; 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. * Returns the shard information.
* *

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins; namespace Pest\Plugins;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
@ -15,21 +14,116 @@ final class Snapshot implements HandlesArguments
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
/**
* Whether snapshots should be updated on this run.
*/
public static bool $updateSnapshots = false;
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
public function handleArguments(array $arguments): array 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)) { if (! $this->hasArgument('--update-snapshots', $arguments)) {
return $arguments; return $arguments;
} }
if ($this->hasArgument('--parallel', $arguments)) { self::$updateSnapshots = true;
throw new InvalidOption('The [--update-snapshots] option is not supported when running in parallel.');
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); 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;
}
} }

1780
src/Plugins/Tia.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,621 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class BaselineSync
{
private const string WORKFLOW_FILE = 'tia-baseline.yml';
private const string ARTIFACT_NAME = 'pest-tia-baseline';
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
private const int FETCH_COOLDOWN_SECONDS = 86400;
private const array DIAGNOSES = [
'network' => [
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
],
'gh-auth' => [
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
'message' => 'authentication failed — run `gh auth login` and retry.',
],
'rate-limit' => [
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
'message' => 'GitHub API rate limit hit — try again later.',
],
'not-found' => [
'pattern' => '/404|not found|repository not found/i',
'message' => 'workflow or artifact not found in repo.',
],
'forbidden' => [
'pattern' => '/403|forbidden|access denied/i',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
],
];
public function __construct(
private State $state,
private OutputInterface $output,
) {}
private function renderBadge(string $type, string $content): void
{
View::render('components.badge', ['type' => $type, 'content' => $content]);
}
private function renderChild(string $text): void
{
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
return false;
}
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->renderBadge('WARN', sprintf(
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
$this->formatDuration($remaining),
));
return false;
}
$result = $this->download($repo, $projectRoot, $hasAnchor);
$payload = $result['payload'];
$failureKind = $result['failureKind'];
if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown();
$this->emitPublishInstructions();
}
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) {
return false;
}
if ($payload['coverage'] !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
$this->clearCooldown();
return true;
}
private function cooldownRemaining(): ?int
{
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
return null;
}
$remaining = $decoded['until'] - time();
return $remaining > 0 ? $remaining : null;
}
private function startCooldown(): void
{
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
]));
}
private function clearCooldown(): void
{
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
}
private function formatDuration(int $seconds): string
{
if ($seconds >= 3600) {
return (int) round($seconds / 3600).'h';
}
if ($seconds >= 60) {
return (int) round($seconds / 60).'m';
}
return $seconds.'s';
}
private function emitPublishInstructions(): void
{
if ($this->isCi()) {
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
return;
}
$this->renderBadge('WARN', 'No baseline published yet — recording locally.');
$this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.');
}
private function isCi(): bool
{
return getenv('GITHUB_ACTIONS') === 'true'
|| getenv('GITLAB_CI') === 'true'
|| getenv('CIRCLECI') === 'true';
}
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($gitConfig)) {
return null;
}
$content = @file_get_contents($gitConfig);
if ($content === false) {
return null;
}
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return $m[1];
}
return null;
}
/**
* @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*/
private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{
$this->validateGhDependencies($hasAnchor);
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return ['payload' => null, 'failureKind' => $listError['kind']];
}
if ($runId === null) {
return ['payload' => null, 'failureKind' => 'no-runs'];
}
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderChild(sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return ['payload' => null, 'failureKind' => null];
}
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
if (! $download['success']) {
return ['payload' => null, 'failureKind' => $download['failureKind']];
}
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
$this->trimDownloadCache($projectRoot);
return ['payload' => $payload, 'failureKind' => null];
}
/**
* @param array{kind: string, message: string} $diagnosis
*/
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
{
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
return;
}
Panic::with(new BaselineFetchFailed(
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
private function validateGhDependencies(bool $hasAnchor): void
{
if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.',
'Install it from https://cli.github.com.',
$hasAnchor,
));
}
if (! $this->ghAuthenticated()) {
Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.',
$hasAnchor,
));
}
}
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId);
$this->output->writeln('');
$this->renderChild($artifactSize !== null
? sprintf(
'Downloading TIA baseline (%s) from %s…',
$this->formatSize($artifactSize),
$repo,
)
: sprintf(
'Downloading TIA baseline from %s…',
$repo,
));
$process = new Process([
'gh', 'run', 'download', $runId,
'-R', $repo,
'-n', self::ARTIFACT_NAME,
'-D', $runCacheDir,
]);
$process->setTimeout(900.0);
$process->start();
$startedAt = microtime(true);
$tick = 0;
while ($process->isRunning()) {
$this->renderDownloadProgress($startedAt, $tick++);
usleep(120_000);
}
$process->wait();
$this->clearProgressLine();
if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Baseline download failed — %s',
$diagnosis['message'],
));
return ['success' => false, 'failureKind' => $diagnosis['kind']];
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
*/
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
{
$payload = $this->readArtifact($runCacheDir);
if ($payload === null) {
$this->cleanup($runCacheDir);
Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).',
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
$hasAnchor,
));
}
return $payload;
}
private function artifactSize(string $repo, string $runId): ?int
{
$process = new Process([
'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
'--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME,
),
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$size = trim($process->getOutput());
return is_numeric($size) ? (int) $size : null;
}
private function renderDownloadProgress(float $startedAt, int $tick): void
{
static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
$elapsed = max(0.0, microtime(true) - $startedAt);
$frame = $frames[$tick % count($frames)];
$this->output->write(sprintf(
"\r\033[K <fg=gray>%s %.1fs elapsed</>",
$frame,
$elapsed,
));
}
private function clearProgressLine(): void
{
$this->output->write("\r\033[K");
}
private function dirSize(string $dir): int
{
if (! is_dir($dir)) {
return 0;
}
$total = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isFile()) {
$total += $entry->getSize();
}
}
return $total;
}
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/
private function readArtifact(string $dir): ?array
{
$graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
$coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
$graph = is_file($graphPath) ? @file_get_contents($graphPath) : false;
if ($graph === false) {
return null;
}
$coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false;
return [
'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage,
'sizeOnDisk' => $this->dirSize($dir),
];
}
private function downloadCacheDir(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
}
private function safeRunId(string $runId): string
{
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
return $sanitised === '' ? 'unknown' : $sanitised;
}
private function trimDownloadCache(string $projectRoot): void
{
$root = $this->downloadCacheDir($projectRoot);
if (! is_dir($root)) {
return;
}
$entries = @scandir($root);
if ($entries === false) {
return;
}
$candidates = [];
foreach ($entries as $entry) {
if (in_array($entry, ['.', '..'], true)) {
continue;
}
$path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) {
continue;
}
$mtime = @filemtime($path);
$candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime];
}
if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) {
return;
}
usort(
$candidates,
static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'],
);
foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) {
$this->cleanup($stale['path']);
}
}
/**
* @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/
private function latestSuccessfulRunIdWithError(string $repo): array
{
$process = new Process([
'gh', 'run', 'list',
'-R', $repo,
'--workflow', self::WORKFLOW_FILE,
'--status', 'success',
'--limit', '1',
'--json', 'databaseId',
'--jq', '.[0].databaseId // empty',
]);
$process->setTimeout(30.0);
$process->run();
if (! $process->isSuccessful()) {
return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())];
}
$runId = trim($process->getOutput());
return [$runId === '' ? null : $runId, null];
}
private function ghAuthenticated(): bool
{
$process = new Process(['gh', 'auth', 'status']);
$process->setTimeout(10.0);
$process->run();
return $process->isSuccessful();
}
/**
* @return array{kind: string, message: string}
*/
private function classifyGhError(string $output): array
{
$output = trim($output);
if ($output === '') {
return ['kind' => 'unknown', 'message' => 'unknown error'];
}
foreach (self::DIAGNOSES as $kind => $diagnosis) {
if (preg_match($diagnosis['pattern'], $output) === 1) {
return ['kind' => $kind, 'message' => $diagnosis['message']];
}
}
return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
}
private function commandExists(string $cmd): bool
{
$process = new Process(['which', $cmd]);
$process->run();
return $process->isSuccessful();
}
private function cleanup(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
/** @var \SplFileInfo $entry */
foreach ($iterator as $entry) {
if ($entry->isDir()) {
@rmdir($entry->getPathname());
} else {
@unlink($entry->getPathname());
}
}
@rmdir($dir);
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return sprintf('%.1f MB', $bytes / 1024 / 1024);
}
if ($bytes >= 1024) {
return sprintf('%.1f KB', $bytes / 1024);
}
return $bytes.' B';
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use Pest\TestSuite;
/**
* @internal
*/
final readonly class Bootstrapper implements BootstrapperContract
{
public function __construct(private Container $container) {}
public function boot(): void
{
$testSuite = $this->container->get(TestSuite::class);
assert($testSuite instanceof TestSuite);
$tempDir = Storage::tempDir($testSuite->rootPath);
$this->container->add(State::class, new FileState($tempDir));
}
}

View File

@ -0,0 +1,326 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final readonly class ChangedFiles
{
public function __construct(private string $projectRoot) {}
/**
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
*/
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{
if ($lastRunTree === []) {
return $files;
}
$candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) {
$candidates[$snapshotted] = true;
}
$remaining = [];
foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null;
$current = $this->currentHash($file);
if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file;
}
}
return $remaining;
}
private function currentHash(string $relativePath): ?string
{
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! is_file($absolute)) {
return null;
}
$hash = ContentHash::of($absolute);
return $hash === false ? null : $hash;
}
/**
* @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash
*/
public function snapshotTree(array $files): array
{
$out = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
$out[$file] = '';
continue;
}
$hash = ContentHash::of($absolute);
if ($hash !== false) {
$out[$file] = $hash;
}
}
return $out;
}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
*/
public function since(?string $sha): ?array
{
$files = [];
if ($sha !== null && $sha !== '') {
if (! $this->shaIsReachable($sha)) {
return null;
}
$files = array_merge($files, $this->diffSinceSha($sha));
}
$files = array_merge($files, $this->workingTreeChanges());
$unique = [];
foreach ($files as $file) {
if ($file === '') {
continue;
}
$unique[$file] = true;
}
$candidates = array_keys($this->filterIgnored($unique));
if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha);
}
return $candidates;
}
/**
* @param array<int, string> $files
* @return array<int, string>
*/
private function filterBehaviourallyUnchanged(array $files, string $sha): array
{
$remaining = [];
foreach ($files as $file) {
$currentHash = $this->currentHash($file);
if ($currentHash === null) {
$remaining[] = $file;
continue;
}
$baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) {
$remaining[] = $file;
continue;
}
if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
$remaining[] = $file;
}
}
return $remaining;
}
private function contentAtSha(string $sha, string $path): ?string
{
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
return $process->getOutput();
}
/**
* @param array<string, true> $candidates
* @return array<string, true>
*/
private function filterIgnored(array $candidates): array
{
if ($candidates === []) {
return $candidates;
}
$process = new Process(
['git', 'check-ignore', '--no-index', '-z', '--stdin'],
$this->projectRoot,
);
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode();
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return $candidates;
}
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
if ($ignored !== '') {
unset($candidates[$ignored]);
}
}
return $candidates;
}
public function currentBranch(): ?string
{
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$branch = trim($process->getOutput());
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
return $process->getExitCode() === 0;
}
/**
* @return array<int, string>
*/
private function diffSinceSha(string $sha): array
{
$process = new Process(
['git', 'diff', '--name-only', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
return $this->splitLines($process->getOutput());
}
/**
* @return array<int, string>
*/
private function workingTreeChanges(): array
{
$process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return [];
}
$records = explode("\x00", rtrim($output, "\x00"));
$files = [];
$count = count($records);
for ($i = 0; $i < $count; $i++) {
$record = $records[$i];
if (strlen($record) < 4) {
continue;
}
$status = substr($record, 0, 2);
$path = substr($record, 3);
if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path;
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
$files[] = $records[$i + 1];
$i++;
}
continue;
}
$files[] = $path;
}
return $files;
}
public function currentSha(): ?string
{
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
throw new MissingDependency('Tia mode', 'git');
}
$sha = trim($process->getOutput());
return $sha === '' ? null : $sha;
}
/**
* @return array<int, string>
*/
private function splitLines(string $output): array
{
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
return $lines === false ? [] : $lines;
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\BladeEdges;
use Pest\Plugins\Tia\Edges\InertiaEdges;
/**
* @internal
*/
final class Collectors
{
/** @var list<class-string> */
private const array COLLECTORS = [
BladeEdges::class,
TableTracker::class,
InertiaEdges::class,
];
public static function armAll(Recorder $recorder): void
{
foreach (self::COLLECTORS as $collector) {
$collector::arm($recorder);
}
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/**
* @internal
*/
final class Configuration
{
/**
* @return $this
*/
public function always(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
return $this;
}
/**
* @return $this
*/
public function locally(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
return $this;
}
/**
* @return $this
*/
public function filtered(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markFiltered();
return $this;
}
/**
* @return $this
*/
public function baselined(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markBaselined();
return $this;
}
/**
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this
*/
public function watch(array $patterns): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->add($patterns);
return $this;
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class ContentHash
{
public static function of(string $absolute): string|false
{
$raw = @file_get_contents($absolute);
if ($raw === false) {
return false;
}
return self::ofContent($absolute, $raw);
}
public static function ofContent(string $path, string $raw): string
{
$lower = strtolower($path);
if (str_ends_with($lower, '.blade.php')) {
return self::hashBladeContent($raw);
}
if (str_ends_with($lower, '.php')) {
return self::hashPhpContent($raw);
}
foreach (['.vue', '.tsx', '.jsx', '.svelte', '.ts', '.js', '.mjs', '.cjs', '.mts'] as $extension) {
if (str_ends_with($lower, $extension)) {
return self::hashJsContent($raw);
}
}
return hash('xxh128', $raw);
}
private static function hashPhpContent(string $raw): string
{
$tokens = @token_get_all($raw);
if ($tokens === []) {
return hash('xxh128', $raw);
}
$normalised = '';
foreach ($tokens as $token) {
if (is_array($token)) {
if ($token[0] === T_WHITESPACE) {
continue;
}
if ($token[0] === T_COMMENT) {
continue;
}
if ($token[0] === T_DOC_COMMENT) {
continue;
}
$normalised .= $token[1];
} else {
$normalised .= $token;
}
}
return hash('xxh128', $normalised);
}
private static function hashBladeContent(string $raw): string
{
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
return hash('xxh128', trim($stripped));
}
private static function hashJsContent(string $raw): string
{
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
$stripped = preg_replace('/^\s*\/\*.*?\*\/\s*$/sm', '', $stripped) ?? $stripped;
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
return hash('xxh128', trim($stripped));
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface State
{
public function read(string $key): ?string;
public function write(string $key, string $content): bool;
public function delete(string $key): bool;
public function exists(string $key): bool;
/**
* @return list<string>
*/
public function keysWithPrefix(string $prefix): array;
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* @internal
*/
interface WatchDefault
{
public function applicable(): bool;
/**
* @return array<string, array<int, string>> pattern → list of project-relative test dirs
*/
public function defaults(string $projectRoot, string $testPath): array;
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use Throwable;
/**
* @internal
*/
final class CoverageCollector
{
/**
* @var array<string, string|null>
*/
private array $classFileCache = [];
/**
* @return array<string, array<int, string>>
*/
public function perTestFiles(): array
{
if (! PhpUnitCodeCoverage::instance()->isActive()) {
return [];
}
try {
$lineCoverage = PhpUnitCodeCoverage::instance()
->codeCoverage()
->getData()
->lineCoverage();
} catch (Throwable) {
return [];
}
/** @var array<string, array<string, true>> $edges */
$edges = [];
foreach ($lineCoverage as $sourceFile => $lines) {
$testIds = [];
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$testIds[$id] = true;
}
}
foreach (array_keys($testIds) as $testId) {
$testFile = $this->testIdToFile($testId);
if ($testFile === null) {
continue;
}
$edges[$testFile][$sourceFile] = true;
}
}
$out = [];
foreach ($edges as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
public function reset(): void
{
$this->classFileCache = [];
}
private function testIdToFile(string $testId): ?string
{
$hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
if (! str_contains($identifier, '::')) {
return null;
}
[$className] = explode('::', $identifier, 2);
if (array_key_exists($className, $this->classFileCache)) {
return $this->classFileCache[$className];
}
$file = $this->resolveClassFile($className);
$this->classFileCache[$className] = $file;
return $file;
}
private function resolveClassFile(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
assert(property_exists($className, '__filename') && is_string($className::$__filename));
return $className::$__filename;
}
}

View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable;
/**
* @internal
*/
final class CoverageMerger
{
public static function applyIfMarked(string $reportPath): void
{
$state = self::state();
if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
$state->delete(Tia::KEY_COVERAGE_MARKER);
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) {
$current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) {
self::primeUncoveredFiles($current);
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
}
return;
}
$decoded = self::decompress($cachedBytes);
if ($decoded === null) {
$state->delete(Tia::KEY_COVERAGE_CACHE);
return;
}
$cached = self::unserializeCoverage($decoded);
$current = self::requireCoverage($reportPath);
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
return;
}
self::primeUncoveredFiles($cached);
self::primeUncoveredFiles($current);
self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current);
$serialised = serialize($cached);
@file_put_contents(
$reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n",
);
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
}
private static function primeUncoveredFiles(CodeCoverage $coverage): void
{
$coverage->getData(false);
}
private static function compress(string $bytes): string
{
$compressed = @gzencode($bytes);
return $compressed === false ? $bytes : $compressed;
}
private static function decompress(string $bytes): ?string
{
$decoded = @gzdecode($bytes);
return $decoded === false ? null : $decoded;
}
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{
$currentIds = self::collectTestIds($current);
if ($currentIds === []) {
return;
}
$cachedData = $cached->getData();
$lineCoverage = $cachedData->lineCoverage();
foreach ($lineCoverage as $file => $lines) {
foreach ($lines as $line => $ids) {
if ($ids === null) {
continue;
}
if ($ids === []) {
continue;
}
$filtered = array_values(array_diff($ids, $currentIds));
if ($filtered !== $ids) {
$lineCoverage[$file][$line] = $filtered;
}
}
}
$cachedData->setLineCoverage($lineCoverage);
}
/**
* @return array<int, string>
*/
private static function collectTestIds(CodeCoverage $coverage): array
{
$ids = [];
foreach ($coverage->getData()->lineCoverage() as $lines) {
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
private static function state(): State
{
$state = Container::getInstance()->get(State::class);
assert($state instanceof State);
return $state;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage
{
if (! is_file($reportPath)) {
return null;
}
try {
/** @var mixed $value */
$value = require $reportPath;
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
private static function unserializeCoverage(string $bytes): ?CodeCoverage
{
try {
$value = @unserialize($bytes);
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class BladeEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER) || ! $app->bound('view')) {
return;
}
$app->instance(self::MARKER, true);
$factory = $app->make('view');
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
return;
}
$factory->composer('*', static function (object $view) use ($recorder): void {
if (! method_exists($view, 'getPath')) {
return;
}
/** @var mixed $path */
$path = $view->getPath();
if (is_string($path) && $path !== '') {
$recorder->linkSource($path);
}
});
}
}

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class InertiaEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
private const string MARKER = 'pest.tia.inertia-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER) || ! $app->bound('events')) {
return;
}
$app->instance(self::MARKER, true);
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
if (! property_exists($event, 'response') || ! is_object($event->response)) {
return;
}
$component = self::extractComponent($event->response);
if ($component !== null) {
$recorder->linkInertiaComponent($component);
}
});
}
private static function extractComponent(object $response): ?string
{
$content = self::readContent($response);
if ($content === null) {
return null;
}
if (self::isInertiaJsonResponse($response)) {
return self::componentFromJson($content);
}
if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
}
if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
return self::componentFromJson(html_entity_decode($match[1]));
}
return null;
}
private static function isInertiaJsonResponse(object $response): bool
{
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
return false;
}
$headers = $response->headers;
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
}
private static function componentFromJson(string $json): ?string
{
/** @var mixed $decoded */
$decoded = json_decode($json, true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
return null;
}
private static function readContent(object $response): ?string
{
if (! method_exists($response, 'getContent')) {
return null;
}
/** @var mixed $content */
$content = $response->getContent();
return is_string($content) ? $content : null;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Enums;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
enum ReplayType
{
case None;
case Pass;
case Risky;
case Skipped;
case Incomplete;
case Failure;
public static function fromStatus(?TestStatus $status): self
{
if (! $status instanceof TestStatus) {
return self::None;
}
return match (true) {
$status->isSuccess() => self::Pass,
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
default => self::Failure,
};
}
}

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
/**
* @internal
*/
final class FileState implements State
{
private readonly string $rootDir;
private ?string $resolvedRoot = null;
public function __construct(string $rootDir)
{
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
}
public function read(string $key): ?string
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return null;
}
$bytes = @file_get_contents($path);
return $bytes === false ? null : $bytes;
}
public function write(string $key, string $content): bool
{
if (! $this->ensureRoot()) {
return false;
}
$path = $this->pathFor($key);
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $content) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
public function delete(string $key): bool
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return true;
}
return @unlink($path);
}
public function exists(string $key): bool
{
return is_file($this->pathFor($key));
}
public function keysWithPrefix(string $prefix): array
{
$root = $this->resolvedRoot();
if ($root === null) {
return [];
}
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
$matches = glob($pattern);
if ($matches === false) {
return [];
}
$keys = [];
foreach ($matches as $path) {
$keys[] = basename($path);
}
return $keys;
}
public function pathFor(string $key): string
{
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
}
private function resolvedRoot(): ?string
{
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir);
if ($resolved === false) {
return null;
}
return $this->resolvedRoot = $resolved;
}
private function ensureRoot(): bool
{
if (is_dir($this->rootDir)) {
return true;
}
if (@mkdir($this->rootDir, 0755, true)) {
return true;
}
return is_dir($this->rootDir);
}
}

View File

@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Finder\Finder;
/**
* @internal
*/
final readonly class Fingerprint
{
private const int SCHEMA_VERSION = 17;
/**
* @return array{
* structural: array<string, int|string|null>,
* environmental: array<string, int|string|null>,
* }
*/
public static function compute(string $projectRoot): array
{
return [
'structural' => [
'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'),
'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'),
// 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
// 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
'vite_config' => self::viteConfigHash($projectRoot),
// 'package_json' => self::packageJsonHash($projectRoot),
'package_lock' => self::packageLockHash($projectRoot),
'js_config' => self::jsConfigHash($projectRoot),
// 'composer_json' => self::composerJsonHash($projectRoot),
],
'environmental' => [
'php_minor' => PHP_MAJOR_VERSION,
// 'extensions' => self::extensionsFingerprint($projectRoot),
// 'env_files' => self::envFilesHash($projectRoot),
],
];
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
public static function structuralMatches(array $a, array $b): bool
{
$aStructural = self::structuralOnly($a);
$bStructural = self::structuralOnly($b);
ksort($aStructural);
ksort($bStructural);
return $aStructural === $bStructural;
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function structuralDrift(array $stored, array $current): array
{
return self::detectDrift(
self::structuralOnly($stored),
self::structuralOnly($current),
'schema',
);
}
/**
* @param array<string, mixed> $stored
* @param array<string, mixed> $current
* @return list<string>
*/
public static function environmentalDrift(array $stored, array $current): array
{
return self::detectDrift(
self::environmentalOnly($stored),
self::environmentalOnly($current),
);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
* @return list<string>
*/
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
{
$drifts = [];
foreach ($a as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function structuralOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'structural');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function environmentalOnly(array $fingerprint): array
{
return self::bucket($fingerprint, 'environmental');
}
/**
* @param array<string, mixed> $fingerprint
* @return array<string, mixed>
*/
private static function bucket(array $fingerprint, string $key): array
{
$raw = $fingerprint[$key] ?? null;
if (! is_array($raw)) {
return [];
}
$normalised = [];
foreach ($raw as $k => $v) {
if (is_string($k)) {
$normalised[$k] = $v;
}
}
return $normalised;
}
private static function viteConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function jsConfigHash(string $projectRoot): ?string
{
$parts = [];
foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) {
if (! self::isTrackedByGit($projectRoot, $name)) {
continue;
}
$hash = self::hashIfExists($projectRoot.'/'.$name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function composerLockHash(string $projectRoot): ?string
{
return self::trackedHash($projectRoot, 'composer.lock');
}
private static function packageLockHash(string $projectRoot): ?string
{
$parts = [];
foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) {
$hash = self::trackedHash($projectRoot, $name);
if ($hash !== null) {
$parts[] = $name.':'.$hash;
}
}
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
}
private static function trackedHash(string $projectRoot, string $relativePath): ?string
{
if (! self::isTrackedByGit($projectRoot, $relativePath)) {
return null;
}
return self::hashIfExists($projectRoot.'/'.$relativePath);
}
/**
* Returns true when the file exists and is not gitignored.
*
* Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo)
* regenerate per-machine with OS-specific optional deps, which would
* otherwise force a fingerprint mismatch on every fetched baseline.
*/
private static function isTrackedByGit(string $projectRoot, string $relativePath): bool
{
if (! is_file($projectRoot.'/'.$relativePath)) {
return false;
}
static $cache = [];
$key = $projectRoot."\0".$relativePath;
if (isset($cache[$key])) {
return $cache[$key];
}
if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) {
return $cache[$key] = true;
}
$finder = (new Finder)
->in($projectRoot)
->depth('== 0')
->name($relativePath)
->ignoreVCSIgnored(true);
return $cache[$key] = $finder->hasResults();
}
private static function contentHashOrNull(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = ContentHash::of($path);
return $hash === false ? null : $hash;
}
private static function hashIfExists(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
}

1485
src/Plugins/Tia/Graph.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,391 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class JsModuleGraph
{
private const int NODE_TIMEOUT_SECONDS = 180;
private const string CACHE_FILE = 'js-module-graph.cache.json';
/**
* @var list<string>
*/
public const array VITE_CONFIG_NAMES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
];
/**
* Candidate page directories, in priority order. Must stay in sync with
* `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs.
*
* @var list<string>
*/
private const array PAGE_DIR_CANDIDATES = [
'resources/js/Pages',
'resources/js/pages',
'assets/js/Pages',
'assets/js/pages',
'assets/Pages',
'assets/pages',
];
/**
* @var list<string>
*/
private const array PAGE_EXTENSIONS = [
'vue', 'svelte',
'tsx', 'jsx',
'ts', 'js',
'mts', 'cts', 'mjs', 'cjs',
];
/**
* @return array<string, list<string>>
*/
public static function build(string $projectRoot): array
{
$result = self::resolve($projectRoot);
return $result ?? [];
}
/**
* @return array<string, list<string>>|null
*/
public static function buildStrict(string $projectRoot): ?array
{
return self::resolve($projectRoot);
}
public static function isApplicable(string $projectRoot): bool
{
if (! self::hasViteConfig($projectRoot)) {
return false;
}
return self::firstExistingPagesDir($projectRoot) !== null;
}
private static function firstExistingPagesDir(string $projectRoot): ?string
{
foreach (self::PAGE_DIR_CANDIDATES as $rel) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel);
if (! is_dir($abs)) {
continue;
}
if (self::dirHasPageFile($abs)) {
return $abs;
}
}
return null;
}
private static function dirHasPageFile(string $dir): bool
{
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
} catch (\UnexpectedValueException) {
return false;
}
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) {
return true;
}
}
return false;
}
/**
* @return array<string, list<string>>|null
*/
private static function resolve(string $projectRoot): ?array
{
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) {
return $cached;
}
}
$process = self::buildNodeProcess($projectRoot);
if (! $process instanceof Process) {
return null;
}
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
private static function buildNodeProcess(string $projectRoot): ?Process
{
if (! self::hasViteConfig($projectRoot)) {
return null;
}
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
return null;
}
$nodeBinary = (new ExecutableFinder)->find('node');
if ($nodeBinary === null) {
return null;
}
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
if (! is_file($helperPath)) {
return null;
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
return $process;
}
/**
* @return array<string, list<string>>|null
*/
private static function parseNodeOutput(string $output): ?array
{
/** @var mixed $decoded */
$decoded = json_decode($output, true);
if (! is_array($decoded)) {
return null;
}
$out = [];
foreach ($decoded as $path => $components) {
if (! is_string($path)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
sort($names);
$out[$path] = $names;
}
}
ksort($out);
return $out;
}
private static function fingerprint(string $projectRoot): ?string
{
$parts = [];
foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) {
continue;
}
$stat = @stat($path);
$bytes = @file_get_contents($path);
$parts[] = 'config:'.$name
.':'.($stat === false ? '0' : (string) $stat['mtime'])
.':'.($stat === false ? '0' : (string) $stat['size'])
.':'.($bytes === false ? '' : hash('sha256', $bytes));
}
if ($parts === []) {
return null;
}
$override = getenv('TIA_VITE_PAGES_DIR');
if (is_string($override) && $override !== '') {
$parts[] = 'pagesDirOverride:'.$override;
}
$pagesDir = self::firstExistingPagesDir($projectRoot);
if ($pagesDir !== null) {
$parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir);
}
$jsRoot = $pagesDir !== null ? dirname($pagesDir) : null;
if ($jsRoot !== null && is_dir($jsRoot)) {
$entries = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY,
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$entries[] = $file->getPathname()
.':'.$file->getSize()
.':'.$file->getMTime();
}
sort($entries);
$parts[] = 'js:'.hash('sha256', implode("\n", $entries));
}
return hash('sha256', implode('|', $parts));
}
/**
* @return array<string, list<string>>|null
*/
private static function readCache(string $projectRoot, string $fingerprint): ?array
{
$path = self::cachePath($projectRoot);
if (! is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
/** @var mixed $decoded */
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
if (($decoded['fingerprint'] ?? null) !== $fingerprint) {
return null;
}
$graph = $decoded['graph'] ?? null;
if (! is_array($graph)) {
return null;
}
$out = [];
foreach ($graph as $key => $value) {
if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue;
}
$names = [];
foreach ($value as $name) {
if (is_string($name) && $name !== '') {
$names[] = $name;
}
}
$out[$key] = $names;
}
return $out;
}
/**
* @param array<string, list<string>> $graph
*/
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{
$path = self::cachePath($projectRoot);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$payload = json_encode([
'fingerprint' => $fingerprint,
'graph' => $graph,
]);
if ($payload === false) {
return;
}
$tmp = $path.'.tmp.'.bin2hex(random_bytes(4));
if (@file_put_contents($tmp, $payload) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
}
private static function cachePath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function hasViteConfig(string $projectRoot): bool
{
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
}
}

View File

@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use ReflectionClass;
/**
* @internal
*/
final class Recorder
{
private ?string $currentTestFile = null;
/** @var array<string, array<string, true>> */
private array $perTestFiles = [];
/** @var array<string, array<string, true>> */
private array $perTestTables = [];
/** @var array<string, array<string, true>> */
private array $perTestInertiaComponents = [];
/** @var array<string, true> */
private array $perTestUsesDatabase = [];
/** @var array<string, string|null> */
private array $classFileCache = [];
/** @var array<string, bool> */
private array $classUsesDatabaseCache = [];
private bool $active = false;
private bool $driverChecked = false;
private bool $driverAvailable = false;
private string $driver = 'none';
private ?SourceScope $sourceScope = null;
public function activate(): void
{
$this->active = true;
}
public function isActive(): bool
{
return $this->active;
}
public function driverAvailable(): bool
{
if (! $this->driverChecked) {
if (function_exists('pcov\\start')) {
$this->driver = 'pcov';
$this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
$modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) {
$this->driver = 'xdebug';
$this->driverAvailable = true;
}
}
$this->driverChecked = true;
}
return $this->driverAvailable;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active || ! $this->driverAvailable()) {
return;
}
if ($this->currentTestFile !== null) {
return;
}
$file = $this->resolveTestFile($className, $fallbackFile);
if ($file === null) {
return;
}
$this->currentTestFile = $file;
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
}
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
return;
}
\xdebug_start_code_coverage();
}
public function endTest(): void
{
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
return;
}
if ($this->driver === 'pcov') {
\pcov\stop();
$scope = $this->sourceScope();
$filesToCollectCoverageFor = [];
foreach (\pcov\waiting() as $file) {
if (is_string($file) && $scope->contains($file)) {
$filesToCollectCoverageFor[] = $file;
}
}
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = $this->filesWithExecutedLines($data);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
\xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data);
}
foreach ($coveredFiles as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
$this->currentTestFile = null;
}
public function linkSource(string $sourceFile): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($sourceFile === '') {
return;
}
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
private function classUsesDatabase(string $className): bool
{
if (array_key_exists($className, $this->classUsesDatabaseCache)) {
return $this->classUsesDatabaseCache[$className];
}
if (! class_exists($className, false)) {
return $this->classUsesDatabaseCache[$className] = false;
}
static $needles = [
'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true,
'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true,
'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true,
];
$reflection = new ReflectionClass($className);
do {
foreach (array_keys($reflection->getTraits()) as $traitName) {
if (isset($needles[$traitName])) {
return $this->classUsesDatabaseCache[$className] = true;
}
}
$reflection = $reflection->getParentClass();
} while ($reflection !== false && ! $reflection->isInternal());
return $this->classUsesDatabaseCache[$className] = false;
}
public function linkTable(string $table): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($table === '') {
return;
}
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
}
public function linkInertiaComponent(string $component): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($component === '') {
return;
}
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
}
/** @return array<string, array<int, string>> */
public function perTestFiles(): array
{
$out = [];
foreach ($this->perTestFiles as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
/** @return array<string, array<int, string>> */
public function perTestTables(): array
{
$out = [];
foreach ($this->perTestTables as $testFile => $tables) {
$names = array_keys($tables);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
/** @return array<string, array<int, string>> */
public function perTestInertiaComponents(): array
{
$out = [];
foreach ($this->perTestInertiaComponents as $testFile => $components) {
$names = array_keys($components);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
/** @return array<string, true> */
public function perTestUsesDatabase(): array
{
return $this->perTestUsesDatabase;
}
private function resolveTestFile(string $className, string $fallbackFile): ?string
{
if (array_key_exists($className, $this->classFileCache)) {
$file = $this->classFileCache[$className];
} else {
$file = $this->readPestFilename($className);
$this->classFileCache[$className] = $file;
}
if ($file !== null) {
return $file;
}
if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) {
return $fallbackFile;
}
return null;
}
private function readPestFilename(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
assert(property_exists($className, '__filename') && is_string($className::$__filename));
return $className::$__filename;
}
/**
* @param array<string, mixed> $data
* @return list<string>
*/
private function filesWithExecutedLines(array $data): array
{
$out = [];
foreach ($data as $file => $lines) {
if (! is_array($lines)) {
continue;
}
$covered = [];
foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) {
$covered[] = $line;
}
}
if ($covered === []) {
continue;
}
$lineKeys = array_keys($lines);
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
continue;
}
$out[] = $file;
}
return $out;
}
private function sourceScope(): SourceScope
{
return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath);
}
public function reset(): void
{
$this->currentTestFile = null;
$this->perTestFiles = [];
$this->perTestTables = [];
$this->perTestInertiaComponents = [];
$this->perTestUsesDatabase = [];
$this->classFileCache = [];
$this->classUsesDatabaseCache = [];
$this->sourceScope = null;
$this->active = false;
}
}

View File

@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?string $currentTestFile = null;
private ?float $startTime = null;
public function testPrepared(string $testId, ?string $testFile = null): void
{
$this->currentTestId = $testId;
$this->currentTestFile = $testFile;
$this->startTime = microtime(true);
}
public function testPassed(): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::success());
}
public function testFailed(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::failure($message));
}
public function testErrored(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::error($message));
}
public function testSkipped(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::skipped($message));
}
public function testIncomplete(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::incomplete($message));
}
public function testRisky(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(TestStatus::risky($message));
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions: int, file?: string}>
*/
public function all(): array
{
return $this->results;
}
public function recordAssertions(string $testId, int $assertions): void
{
if (isset($this->results[$testId])) {
$this->results[$testId]['assertions'] = $assertions;
}
}
/**
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/
public function merge(array $results): void
{
foreach ($results as $testId => $result) {
$this->results[$testId] = $result;
}
}
public function reset(): void
{
$this->results = [];
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
public function finishTest(): void
{
$this->currentTestId = null;
$this->currentTestFile = null;
$this->startTime = null;
}
private function record(TestStatus $status): void
{
if ($this->currentTestId === null) {
return;
}
$time = $this->startTime !== null
? round(microtime(true) - $this->startTime, 3)
: 0.0;
$existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [
'status' => $status->asInt(),
'message' => $status->message(),
'time' => $time,
'assertions' => $existing['assertions'] ?? 0,
];
if ($this->currentTestFile !== null) {
$this->results[$this->currentTestId]['file'] = $this->currentTestFile;
}
}
}

View File

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* @internal
*/
final class SourceScope
{
/** @var array<string, bool> */
private array $containsCache = [];
private const array TOP_LEVEL_NOISE = [
'vendor',
'node_modules',
'.git',
'.idea',
'.vscode',
'.github',
'.pest',
'.phpunit.cache',
'.cache',
];
private const array NESTED_NOISE = [
'storage/framework',
'storage/logs',
'bootstrap/cache',
];
/**
* @param list<string> $includes Absolute, normalised directory paths.
* @param list<string> $excludes Absolute, normalised directory paths.
*/
public function __construct(
private readonly array $includes,
private readonly array $excludes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$phpunitIncludes = [];
$phpunitExcludes = [];
try {
$source = Registry::get()->source();
foreach ($source->includeDirectories() as $dir) {
$phpunitIncludes[] = self::normalise($dir->path());
}
foreach ($source->excludeDirectories() as $dir) {
$phpunitExcludes[] = self::normalise($dir->path());
}
} catch (Throwable) {
// Registry not initialized — fall back to project-root scanning.
}
$rootIncludes = self::topLevelProjectDirs($projectRoot);
$includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes]));
$excludes = array_values(array_unique([
...$phpunitExcludes,
...self::nestedNoiseDirs($projectRoot),
]));
if ($includes === []) {
$includes = [self::normalise($projectRoot)];
}
return new self($includes, $excludes);
}
/**
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
*/
public static function testPaths(): array
{
try {
$suites = Registry::get()->testSuite();
} catch (Throwable) {
return [];
}
$out = [];
foreach ($suites as $suite) {
foreach ($suite->directories() as $directory) {
$out[] = self::normalise($directory->path());
}
foreach ($suite->files() as $file) {
$out[] = self::normalise($file->path());
}
}
return array_values(array_unique($out));
}
public function contains(string $absoluteFile): bool
{
if (isset($this->containsCache[$absoluteFile])) {
return $this->containsCache[$absoluteFile];
}
$real = @realpath($absoluteFile);
$candidate = $real === false ? $absoluteFile : $real;
$candidate = self::normalise($candidate);
foreach ($this->excludes as $excluded) {
if ($this->startsWithDir($candidate, $excluded)) {
return $this->containsCache[$absoluteFile] = false;
}
}
foreach ($this->includes as $included) {
if ($this->startsWithDir($candidate, $included)) {
return $this->containsCache[$absoluteFile] = true;
}
}
return $this->containsCache[$absoluteFile] = false;
}
/**
* @return list<string>
*/
private static function topLevelProjectDirs(string $projectRoot): array
{
$entries = @scandir($projectRoot);
if ($entries === false) {
return [];
}
$out = [];
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
continue;
}
if ($entry !== '' && $entry[0] === '.') {
continue;
}
$abs = $projectRoot.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($abs)) {
continue;
}
$out[] = self::normalise(@realpath($abs) ?: $abs);
}
return $out;
}
/**
* @return list<string>
*/
private static function nestedNoiseDirs(string $projectRoot): array
{
$out = [];
foreach (self::NESTED_NOISE as $relative) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
$out[] = self::normalise(@realpath($abs) ?: $abs);
}
return $out;
}
private static function normalise(string $path): string
{
return rtrim($path, '/\\');
}
private function startsWithDir(string $candidate, string $dir): bool
{
if ($candidate === $dir) {
return true;
}
return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR);
}
}

146
src/Plugins/Tia/Storage.php Normal file
View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class Storage
{
public static function tempDir(string $projectRoot): string
{
$home = self::homeDir();
if ($home === null) {
return $projectRoot
.DIRECTORY_SEPARATOR.'.pest'
.DIRECTORY_SEPARATOR.'tia';
}
return $home
.DIRECTORY_SEPARATOR.'.pest'
.DIRECTORY_SEPARATOR.'tia'
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
}
public static function purge(string $projectRoot): void
{
$dir = self::tempDir($projectRoot);
if (! is_dir($dir)) {
return;
}
self::removeRecursive($dir);
}
private static function removeRecursive(string $dir): void
{
$entries = @scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue;
}
$path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) {
self::removeRecursive($path);
continue;
}
@unlink($path);
}
@rmdir($dir);
}
private static function homeDir(): ?string
{
foreach (['HOME', 'USERPROFILE'] as $key) {
$value = getenv($key);
if (is_string($value) && $value !== '' && is_dir($value)) {
return rtrim($value, '/\\');
}
}
return null;
}
/**
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
*/
private static function projectKey(string $projectRoot): string
{
$origin = self::originIdentity($projectRoot);
$realpath = @realpath($projectRoot);
$input = $origin ?? ($realpath === false ? $projectRoot : $realpath);
$hash = substr(hash('sha256', $input), 0, 16);
$slug = self::slug(basename($projectRoot));
return $slug === '' ? $hash : $slug.'-'.$hash;
}
private static function originIdentity(string $projectRoot): ?string
{
$url = self::rawOriginUrl($projectRoot);
if ($url === null) {
return null;
}
// git@host:org/repo(.git)
if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return strtolower($m[1].'/'.$m[2]);
}
// scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file
if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
return strtolower($m[1].'/'.$m[2]);
}
return strtolower($url);
}
private static function rawOriginUrl(string $projectRoot): ?string
{
$config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($config)) {
return null;
}
$raw = @file_get_contents($config);
if ($raw === false) {
return null;
}
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) {
return trim($match[1]);
}
return null;
}
private static function slug(string $name): string
{
$slug = strtolower($name);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
return trim($slug, '-');
}
}

View File

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableExtractor
{
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
/**
* @return list<string> Sorted, deduped table names referenced by the
*/
public static function fromSql(string $sql): array
{
$trimmed = ltrim($sql);
if ($trimmed === '') {
return [];
}
$prefix = strtolower(substr($trimmed, 0, 6));
$matched = array_any(self::DML_PREFIXES, fn (string $dml): bool => str_starts_with($prefix, $dml));
if (! $matched) {
return [];
}
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
if (preg_match_all($pattern, $sql, $matches) === false) {
return [];
}
$tables = [];
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
$name = $matches[1][$i] !== ''
? $matches[1][$i]
: ($matches[2][$i] !== ''
? $matches[2][$i]
: ($matches[3][$i] !== ''
? $matches[3][$i]
: $matches[4][$i]));
if ($name === '') {
continue;
}
if (self::isSchemaMeta($name)) {
continue;
}
$tables[strtolower($name)] = true;
}
$out = array_keys($tables);
sort($out);
return $out;
}
/**
* @return list<string> Table names referenced by `Schema::` calls,
*/
public static function fromMigrationSource(string $php): array
{
$tables = [];
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
foreach ($matches[1] as $i => $primary) {
$tables[strtolower($primary)] = true;
$secondary = $matches[2][$i] ?? '';
if ($secondary !== '') {
$tables[strtolower($secondary)] = true;
}
}
}
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
foreach ($matches[1] as $primary) {
$lower = strtolower($primary);
if (! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$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) {
if (preg_match_all($pattern, $php, $matches) === false) {
continue;
}
foreach ($matches[1] as $name) {
$lower = strtolower($name);
if (! self::isSchemaMeta($lower)) {
$tables[$lower] = true;
}
}
}
$out = array_keys($tables);
sort($out);
return $out;
}
private static function isSchemaMeta(string $name): bool
{
$lower = strtolower($name);
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|| str_starts_with($lower, 'pg_')
|| str_starts_with($lower, 'information_schema');
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class TableTracker
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.table-tracker-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('db')) {
return;
}
$app->instance(self::MARKER, true);
$listener = static function (object $query) use ($recorder): void {
if (! property_exists($query, 'sql')) {
return;
}
/** @var mixed $sql */
$sql = $query->sql;
if (! is_string($sql) || $sql === '') {
return;
}
foreach (TableExtractor::fromSql($sql) as $table) {
$recorder->linkTable($table);
}
};
/** @var object $db */
$db = $app->make('db');
if (is_callable([$db, 'listen'])) {
/** @var callable $listen */
$listen = [$db, 'listen'];
$listen($listener);
return;
}
if (! $app->bound('events')) {
return;
}
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
}
}

View File

@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/**
* Resolves the set of project-relative paths that are considered test files,
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
* configuration when no config file is present.
*
* @internal
*/
final readonly class TestPaths
{
/**
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
* @param list<string> $files Project-relative file paths.
* @param list<string> $suffixes Filename suffixes (e.g. '.php').
*/
public function __construct(
private array $directories,
private array $files,
private array $suffixes,
) {}
public static function fromProjectRoot(string $projectRoot): self
{
$directories = [];
$files = [];
$suffixes = [];
try {
$configuration = Registry::get();
foreach ($configuration->testSuite() as $suite) {
foreach ($suite->directories() as $directory) {
$rel = self::toRelative($directory->path(), $projectRoot);
if ($rel !== null) {
$directories[] = $rel;
}
$suffix = $directory->suffix();
if ($suffix !== '') {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
foreach ($suite->files() as $file) {
$rel = self::toRelative($file->path(), $projectRoot);
if ($rel !== null) {
$files[] = $rel;
}
}
}
if ($suffixes === []) {
foreach ($configuration->testSuffixes() as $suffix) {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
} catch (Throwable) {
// Registry not initialized — fall through to defaults.
}
if ($suffixes === []) {
$suffixes = ['.php'];
}
if ($directories === [] && $files === []) {
$fallback = self::testSuiteFallback($projectRoot);
if ($fallback !== null) {
$directories[] = $fallback;
}
}
return new self(
array_values(array_unique($directories)),
array_values(array_unique($files)),
array_values(array_unique($suffixes)),
);
}
public function isTestFile(string $relativePath): bool
{
if (in_array($relativePath, $this->files, true)) {
return true;
}
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
if (! $matchesSuffix) {
return false;
}
foreach ($this->directories as $dir) {
if ($dir === '') {
continue;
}
if (str_starts_with($relativePath, $dir.'/')) {
return true;
}
}
return false;
}
private static function toRelative(string $value, string $projectRoot): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$real = @realpath($value);
$resolved = $real === false ? $value : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
if (! str_starts_with($resolved.'/', $root)) {
return null;
}
return rtrim(substr($resolved, strlen($root)), '/');
}
private static function testSuiteFallback(string $projectRoot): ?string
{
try {
$testPath = TestSuite::getInstance()->testPath;
} catch (Throwable) {
return null;
}
$real = @realpath($testPath);
$resolved = $real === false ? $testPath : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
if (! str_starts_with($resolved.'/', $root)) {
return null;
}
return rtrim(substr($resolved, strlen($root)), '/');
}
}

View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* @internal
*/
final readonly class Browser implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [
'resources/js/** !*.php',
'resources/css/** !*.php',
'public/hot !*.php',
'public/** !*.php',
];
$patterns = [];
foreach ($globs as $glob) {
$patterns[$glob] = $browserTargets;
}
return $patterns;
}
/**
* @return array<int, string>
*/
public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
{
$targets = [];
$candidate = $testPath.'/Browser';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$targets[] = $candidate;
}
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename);
if (! $factory instanceof TestCaseFactory) {
continue;
}
foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = self::fileRelative($projectRoot, $filename);
if ($rel !== null) {
$targets[] = $rel;
}
break;
}
}
}
}
return array_values(array_unique($targets));
}
private static function fileRelative(string $projectRoot, string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Inertia implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& (InstalledVersions::isInstalled('inertiajs/inertia-laravel')
|| InstalledVersions::isInstalled('rompetomp/inertia-bundle'));
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'resources/js/** !*.php' => [$testPath],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Laravel implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'database/migrations/**/*.php' => [$testPath],
'storage/fixtures/**/*' => [$testPath],
'app/** !*.php' => [$testPath],
'resources/views/**' => [$testPath],
'lang/**' => [$testPath],
'resources/lang/**' => [$testPath],
'vite.config.* !*.php' => [$testPath],
'webpack.mix.* !*.php' => [$testPath],
'tailwind.config.* !*.php' => [$testPath],
'postcss.config.* !*.php' => [$testPath],
];
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Livewire implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('livewire/livewire');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath],
'resources/views/pages/**/*.blade.php' => [$testPath],
'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath],
];
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Php implements WatchDefault
{
public function applicable(): bool
{
return true;
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'.env' => [$testPath],
'.env.testing' => [$testPath],
'.env.local' => [$testPath],
'.env.*.local' => [$testPath],
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
'phpunit.xml*' => [$testPath],
$testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath],
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
];
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Plugins\Tia\Contracts\WatchDefault;
/**
* @internal
*/
final readonly class Symfony implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('symfony/framework-bundle');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
'config/** !*.php' => [$testPath],
'config/routes/** !*.php' => [$testPath],
'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath],
'templates/** !*.php' => [$testPath],
'translations/** !*.php' => [$testPath],
'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath],
'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath],
'assets/** !*.php' => [$testPath],
];
}
}

View File

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\WatchDefault;
use Pest\TestSuite;
/**
* @internal
*/
final class WatchPatterns
{
/**
* @var array<int, class-string<WatchDefault>>
*/
private const array DEFAULTS = [
WatchDefaults\Php::class,
WatchDefaults\Laravel::class,
WatchDefaults\Symfony::class,
WatchDefaults\Livewire::class,
WatchDefaults\Inertia::class,
WatchDefaults\Browser::class,
];
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
/**
* @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
*/
private array $patterns = [];
/**
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
*/
private array $parsed = [];
private bool $enabled = false;
private bool $locally = false;
private bool $filtered = false;
private bool $baselined = false;
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
foreach (self::DEFAULTS as $class) {
$default = new $class;
if (! $default->applicable()) {
continue;
}
foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], $dirs),
));
}
}
}
/**
* @param array<string, string> $patterns pattern key → project-relative test dir/file
*/
public function add(array $patterns): void
{
foreach ($patterns as $key => $dir) {
$this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$key] ?? [], [$dir]),
));
}
}
/**
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files.
*/
public function matchedDirectories(string $projectRoot, array $changedFiles): array
{
if ($this->patterns === []) {
return [];
}
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $key => $dirs) {
if (! $this->keyMatches($key, $file)) {
continue;
}
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
return array_keys($matched);
}
/**
* @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string>
*/
public function testsUnderDirectories(array $directories, array $allTestFiles): array
{
if ($directories === []) {
return [];
}
$affected = [];
foreach ($allTestFiles as $testFile) {
foreach ($directories as $target) {
if ($testFile === $target) {
$affected[] = $testFile;
break;
}
$prefix = rtrim($target, '/').'/';
if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile;
break;
}
}
}
return $affected;
}
public function markEnabled(): void
{
$this->enabled = true;
}
public function isEnabled(): bool
{
return $this->enabled;
}
public function markLocally(): void
{
$this->locally = true;
}
public function isLocally(): bool
{
return $this->locally;
}
public function markFiltered(): void
{
$this->filtered = true;
}
public function isFiltered(): bool
{
return $this->filtered;
}
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void
{
$this->patterns = [];
$this->parsed = [];
$this->enabled = false;
$this->locally = false;
$this->filtered = false;
$this->baselined = false;
}
private function keyMatches(string $key, string $file): bool
{
$rule = $this->parse($key);
if (! $this->globMatches($rule['include'], $file)) {
return false;
}
$file = str_replace('\\', '/', $file);
if ($this->touchesVcs($file)) {
return false;
}
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
return false;
}
foreach ($rule['excludes'] as $exclude) {
if ($this->excludeMatches($exclude, $file)) {
return false;
}
}
return true;
}
/**
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
*/
private function parse(string $key): array
{
if (isset($this->parsed[$key])) {
return $this->parsed[$key];
}
$tokens = preg_split('/\s+/', trim($key)) ?: [];
$include = '';
$excludes = [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if ($token[0] === '!') {
$excludes[] = substr($token, 1);
continue;
}
if ($include === '') {
$include = $token;
}
}
return $this->parsed[$key] = [
'include' => $include,
'excludes' => $excludes,
'allowDotfiles' => $this->patternTargetsDotfiles($include),
];
}
private function patternTargetsDotfiles(string $pattern): bool
{
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function touchesVcs(string $file): bool
{
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
}
private function touchesDotfile(string $file): bool
{
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function excludeMatches(string $exclude, string $file): bool
{
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
if ($this->globMatches($pattern, $file)) {
return true;
}
return $this->globMatches($exclude, basename($file));
}
private function globMatches(string $pattern, string $file): bool
{
$pattern = str_replace('\\', '/', $pattern);
$file = str_replace('\\', '/', $file);
$regex = '';
$len = strlen($pattern);
$i = 0;
while ($i < $len) {
$c = $pattern[$i];
if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
$regex .= '.*';
$i += 2;
if (isset($pattern[$i]) && $pattern[$i] === '/') {
$i++;
}
} elseif ($c === '*') {
$regex .= '[^/]*';
$i++;
} elseif ($c === '?') {
$regex .= '[^/]';
$i++;
} else {
$regex .= preg_quote($c, '#');
$i++;
}
}
return (bool) preg_match('#^'.$regex.'$#', $file);
}
}

View File

@ -59,8 +59,10 @@ final class SnapshotRepository
{ {
$snapshotFilename = $this->getSnapshotFilename(); $snapshotFilename = $this->getSnapshotFilename();
if (! file_exists(dirname($snapshotFilename))) { $directory = dirname($snapshotFilename);
mkdir(dirname($snapshotFilename), 0755, true);
if (! is_dir($directory)) {
@mkdir($directory, 0755, true);
} }
file_put_contents($snapshotFilename, $snapshot); file_put_contents($snapshotFilename, $snapshot);

View File

@ -113,6 +113,16 @@ final class TestRepository
$this->testCaseMethodFilters[] = $filter; $this->testCaseMethodFilters[] = $filter;
} }
/**
* Gets the class and traits configured for the given directory path.
*
* @return array<int, string>
*/
public function getUsesForPath(string $path): array
{
return $this->uses[$path][0] ?? [];
}
/** /**
* Gets the test case factory from the given filename. * Gets the test case factory from the given filename.
*/ */

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
/**
* @internal
*/
final class PcovRestarter implements Restarter
{
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! extension_loaded('pcov')) {
return;
}
if (getenv(self::ENV_RESTARTED) === '1') {
putenv(self::ENV_RESTARTED);
unset($_ENV[self::ENV_RESTARTED]);
return;
}
if (! Tia::isEnabledForRun($arguments)) {
return;
}
$desired = $this->normalise($projectRoot);
$current = $this->normalise((string) ini_get('pcov.directory'));
if ($current === $desired) {
return;
}
$this->restart($projectRoot, $arguments);
}
/**
* @param array<int, string> $arguments
*/
private function restart(string $projectRoot, array $arguments): void
{
$env = $this->inheritEnv();
$env[self::ENV_RESTARTED] = '1';
$command = array_merge(
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
array_values($arguments),
);
$proc = @proc_open(
$command,
[STDIN, STDOUT, STDERR],
$pipes,
null,
$env,
);
if (! is_resource($proc)) {
return;
}
$exitCode = proc_close($proc);
exit($exitCode === -1 ? 1 : $exitCode);
}
/**
* @return array<string, string>
*/
private function inheritEnv(): array
{
$env = [];
foreach (getenv() as $name => $value) {
$env[$name] = $value;
}
return $env;
}
private function normalise(string $path): string
{
return rtrim($path, '/\\');
}
}

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* @internal
*/
final class XdebugRestarter implements Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! $this->xdebugIsCoverageOnly()) {
return;
}
if (! $this->runLooksDroppable($arguments, $projectRoot)) {
return;
}
new XdebugHandler('pest')->check();
}
private function xdebugIsCoverageOnly(): bool
{
if (! function_exists('xdebug_info')) {
return false;
}
$modes = @xdebug_info('mode');
if (! is_array($modes)) {
return false;
}
$modes = array_values(array_filter($modes, is_string(...)));
if ($modes === []) {
return true;
}
return $modes === ['coverage'];
}
/**
* @param array<int, string> $arguments
*/
private function runLooksDroppable(array $arguments, string $projectRoot): bool
{
foreach ($arguments as $value) {
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--fresh') {
return false;
}
}
if (! Tia::isEnabledForRun($arguments)) {
return false;
}
return $this->tiaWillReplay($projectRoot);
}
private function tiaWillReplay(string $projectRoot): bool
{
$path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
if (! is_file($path)) {
return false;
}
$json = @file_get_contents($path);
if ($json === false) {
return false;
}
$graph = Graph::decode($json, $projectRoot);
if (! $graph instanceof Graph) {
return false;
}
return Fingerprint::structuralMatches(
$graph->fingerprint(),
Fingerprint::compute($projectRoot),
);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
{
public function __construct(private ResultCollector $collector) {}
public function notify(Finished $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->recordAssertions(
$test->className().'::'.$test->methodName(),
$event->numberOfAssertionsPerformed(),
);
}
$this->collector->finishTest();
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final readonly class EnsureTiaEnds implements FinishedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Finished $event): void
{
$this->recorder->endTest();
}
}

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