Compare commits

...

255 Commits

Author SHA1 Message Date
e54e4a0178 release: 4.0.3 2025-08-24 15:17:23 +01:00
7749775f50 chore: uses phpunit 12.3.6 2025-08-24 15:17:19 +01:00
f11f3aa0a4 Merge pull request #1464 from gehrisandro/sandro/prevent-duplicate-attributes
Prevent duplicate attributes
2025-08-23 22:43:15 +01:00
33817013fe Prevent duplicate attributes 2025-08-23 12:35:32 +02:00
7f11ace329 release: 4.0.2 2025-08-22 11:34:24 +01:00
3d776f1f20 fix: revert reading coverage by chunks 2025-08-22 11:12:55 +01:00
d5ced0a5ca release: 4.0.1 2025-08-22 09:24:07 +01:00
af1e214be4 chore: bumps dependencies 2025-08-22 09:22:16 +01:00
7f9b50974a Merge pull request #1460 from Admiral-Enigma/4.x
Cast "testdox-columns" to an int
2025-08-22 09:20:44 +01:00
cd5272d8cc Cast "testdox-columns" to an int 2025-08-22 10:00:49 +02:00
a7b2039175 Merge branch '3.x' into 4.x 2025-08-20 20:14:15 +01:00
72cf695554 release: 3.8.4 2025-08-20 20:12:42 +01:00
50960a96e9 docs: adjusts release script 2025-08-20 15:30:59 +01:00
507df757a1 release: 4.0.0 2025-08-20 15:29:23 +01:00
8722b3fc3c docs: adjusts readme for 4.x 2025-08-20 15:03:36 +01:00
19eca6e338 fix: skip windows for now 2025-08-20 14:50:15 +01:00
6b523d6963 fix: puts back windows 2025-08-20 14:27:53 +01:00
a350545803 fix: windows check 2025-08-20 14:25:49 +01:00
71c2e97c9f chore: bumps dependencies 2025-08-20 14:23:16 +01:00
98a12012bf Merge branch '3.x' into 4.x 2025-08-20 14:22:03 +01:00
027f4e4832 chore: bumps dependencies 2025-08-20 14:21:14 +01:00
92523a6f39 chore: uses phpunit v12.3.5 2025-08-20 08:01:45 +01:00
ed38fb644f release: 4.0.0 rc 1 2025-08-20 07:58:51 +01:00
39b66bf01d Merge branch '3.x' into 4.x 2025-08-20 07:04:50 +01:00
165c879fe6 release: 3.8.3 2025-08-19 11:11:21 +01:00
4c8bf4b2fd chore: uses phpunit v11.5.33 2025-08-19 11:11:10 +01:00
1ee36f584d Merge branch '3.x' into 4.x 2025-08-15 17:12:37 +01:00
1b0a846a81 Update README.md 2025-08-15 17:11:53 +01:00
e3e518747f release: beta 2 2025-08-11 15:45:37 +01:00
0b96b8f630 Allows old version of phpunit for now 2025-08-06 13:17:17 +01:00
711a60c2db release: beta 1 2025-08-05 17:44:12 +01:00
e7132fa012 fix: removes parse_str from security preset 2025-08-03 16:09:59 -06:00
3b72bbd7fe fix: toMatchObject accept objects 2025-08-03 11:08:11 -06:00
273edb864c chore: adjusts tests 2025-08-03 10:32:14 -06:00
fcb60f3c4a chore: improves wording 2025-08-03 09:57:52 -06:00
91bb7589e2 fix: wording on exception 2025-08-03 09:31:31 -06:00
e524bf5f73 fix: filter by dataset name 2025-07-30 15:52:40 -06:00
27414ce19f Merge pull request #1372 from soyuka/patch-1
throw exception instead of using Panic in the TestSuite.php
2025-07-30 18:27:13 +01:00
fbc9e704e2 Merge pull request #1424 from joelbutcher/fix/throw-if-tests-directory-does-not-exist
[3.x] Throw a fatal exception if the tests directory does not exist
2025-07-30 18:26:31 +01:00
ee6b3ed062 fix: throw a fatal exception if the tests directory does not exist 2025-07-29 19:13:33 +01:00
4c88590b89 feat: not.toHaveSuspiciousCharacters 2025-07-26 07:47:00 -06:00
66e59efec6 Merge branch '3.x' into 4.x 2025-07-26 07:35:23 -06:00
f692be3637 chore: bumps dependencies 2025-07-26 07:34:25 -06:00
127ad618d3 chore: style 2025-07-26 07:34:19 -06:00
da04ba62a8 Merge pull request #1287 from mortenscheel/patch-1
Add toNotIncludeSuspiciousCharacters() expectation
2025-07-26 04:30:22 +01:00
d187566e63 chore: updates snapshots 2025-07-25 21:29:26 -06:00
3e86e158b2 Merge pull request #1304 from jshayes/fix/sibling-describe-blocks
Fix an issue with describe blocks with matching names
2025-07-26 04:26:19 +01:00
d6c6489e93 Merge branch '4.x' into fix/sibling-describe-blocks 2025-07-26 04:26:10 +01:00
ee70a3cfea Merge pull request #1319 from pestphp/allow-custom-arch-expectations
Allow custom arch expectations
2025-07-26 04:23:49 +01:00
7a6f33f139 Merge branch '3.x' into 4.x 2025-07-25 21:20:15 -06:00
55218bcf78 Merge pull request #1324 from bibrokhim/add-attributes-to-laravel-preset
Add Attributes to Laravel preset
2025-07-26 04:19:54 +01:00
e29302300f Merge branch '3.x' into 4.x 2025-07-25 21:17:48 -06:00
2a47b514ec Merge pull request #1351 from cndrsdrmn/patch-1
fix: add ignoring clause for `App\Features\Concerns` on Laravel Preset
2025-07-26 04:17:21 +01:00
222ed174bc style 2025-07-25 21:10:05 -06:00
2aa32569f0 fix: adjust snapshots 2025-07-25 21:06:49 -06:00
1d8d1a046f Merge pull request #1357 from drsdre/patch-1
toMatchArray/Object wrong field fix
2025-07-26 04:06:12 +01:00
3d9ceb1cf2 Merge pull request #1373 from clementbirkle/3.x
fix: normalize snapshot paths for tests outside the main tests directory
2025-07-26 03:58:40 +01:00
520a5fe29d Merge branch '4.x' into 3.x 2025-07-26 03:56:09 +01:00
de4409e368 fix: before all 2025-07-25 20:54:37 -06:00
6d6e4e040f fix: wrong status code being used 2025-07-25 18:03:58 -06:00
aac08629f7 ci: removes testing against lowest 2025-07-23 08:26:12 +01:00
fe27012bbc style 2025-07-22 23:58:36 +01:00
f9901245f1 updates dependencies and snapshots 2025-07-22 23:51:32 +01:00
21e22decf3 Merge pull request #1299 from FaSe22/handle-c-flag
fix: Pest ignores '-c' option
2025-07-22 23:12:07 +01:00
e513f76ea9 Merge pull request #1256 from mazesec/add-slugify-method
Add slugify Method to Str Class and toBeSlug Assertion to Expectation Class
2025-07-22 23:08:15 +01:00
be9c95e3bc feat: adds see 2025-07-22 23:06:43 +01:00
9172721ce8 Merge pull request #1241 from mertasan/ide-reference
feat: Add `references` method for two-way test-source linking
2025-07-22 22:48:26 +01:00
924dc016cc feat: skipLocally 2025-07-22 22:40:38 +01:00
f49b91ec0d fixes missing condition 2025-07-22 22:31:08 +01:00
516ace85b4 fix: skipOnCI 2025-07-22 22:08:03 +01:00
f9814793dd feat: skipOnCI 2025-07-22 21:52:06 +01:00
00572f5f8e feat: improves playwright 2025-07-22 11:39:34 +01:00
fb282b184e fix: return type 2025-07-21 13:32:03 +01:00
e0695a13cb feat: adds shell 2025-07-21 13:25:50 +01:00
8f810bf2a2 Merge pull request #1408 from JonPurvis/remove-language-option
[4.x] Remove Language Option from Profanity Composer Script
2025-07-14 10:37:14 +01:00
84636cee96 Merge branch '4.x' into remove-language-option 2025-07-14 10:37:08 +01:00
0355119afc fix: snapshots feedback 2025-07-06 14:29:18 +01:00
9d0410ee0b feat: adjusts only for browser debug 2025-07-06 13:45:33 +01:00
0d148c2a67 chroe: bumps phpunit 2025-07-05 15:46:03 +01:00
0f1e87c726 Adds output about sharding 2025-07-05 15:43:43 +01:00
73bf579da3 chore: code refactor 2025-07-02 00:26:15 +01:00
5def62018b fix: shard regex 2025-07-01 11:00:05 +01:00
d8e1b27491 ci: fix included version 2025-06-30 22:50:57 +01:00
2ff4713968 ci: fix missing dep 2025-06-30 22:26:09 +01:00
3f27352560 feat: adds --shard 2025-06-30 22:15:07 +01:00
af3fdceddb feat: adds phpunit 12.2.5 support 2025-06-28 18:31:45 +01:00
3faeede1ef chore: fixes snapshots 2025-06-28 18:24:19 +01:00
0bc3219a2b feat: moves visit to the core 2025-06-28 18:18:26 +01:00
a22013a7d3 fix: with types 2025-06-28 12:14:45 +01:00
7fc69033f8 chore: adjusts style 2025-06-27 02:15:36 +01:00
ef76c04dbe feat: adds fixture 2025-06-27 02:15:28 +01:00
7d77bbf1bb Merge pull request #1410 from JonPurvis/remove-period
Remove Period from `ShouldNotHappen` message
2025-06-23 19:29:01 +01:00
163479ae60 chore: style 2025-06-16 10:14:16 +01:00
c3bfdf130e chore: type checking 2025-06-16 10:14:04 +01:00
8c403a57c2 fix: upper case fix 2025-06-16 10:04:00 +01:00
97c136cd94 link to issues page 2025-06-16 02:55:11 +01:00
d6cbd12d8b remove period from message 2025-06-16 02:51:48 +01:00
49bf00024f fix: coverage when coverage file is over 2.4gb on mac os 2025-06-15 22:43:59 +01:00
dd44ac4195 remove language option from profanity composer script 2025-06-14 21:36:39 +01:00
5d2aafd2a3 feat: --profanity 2025-06-12 00:38:05 +01:00
0fc9d4dfe0 feat: adds phpunit 12.2.1 support 2025-06-08 15:29:23 +01:00
02b1ffb334 chore: bump dependencies 2025-05-27 11:37:29 +01:00
c62cc3fef0 chore: adds pokio 2025-05-23 05:19:56 +01:00
909d778da3 fix: undefined property 2025-05-21 02:00:15 +01:00
7711a52fe9 Bumps dependencies 2025-05-09 13:10:29 +01:00
99c9f4e5d8 Bumps dependencies 2025-05-03 11:58:03 +01:00
a310796165 Fixes filtering tests 2025-04-29 11:38:33 +01:00
db9243ca2e bump dependencies 2025-04-29 09:57:02 +01:00
635e3b4c41 chore: deprecates php 8.2 2025-04-20 23:02:19 +01:00
791734a29c Fixes tests 2025-04-20 22:19:25 +01:00
8cfb0acf46 bump paratest 2025-04-20 21:51:01 +01:00
bf67407ba5 Merge pull request #1391 from MrPunyapal/feat/phpunit-12
[WIP] Feat: PHPUnit 12
2025-04-20 21:35:32 +01:00
efdc84e115 Merge branch '4.x' into feat/phpunit-12 2025-04-20 21:35:25 +01:00
d1608bf33d chore: prepares for 4.x 2025-04-20 21:33:50 +01:00
4f6140fdb1 refactor: move test case initialization to a separate method in Testable trait 2025-04-20 15:37:02 +05:30
442a58d07f refactor: comment arch presets in Arch.php 2025-04-20 15:19:56 +05:30
19e9267021 fix: update PHPUnit version 2025-04-20 15:19:40 +05:30
c6244a8712 Release 3.8.2 2025-04-17 11:53:02 +01:00
eed68f2840 Adjusts sponsors 2025-04-13 17:15:23 +01:00
6080f51a0b release: v3.8.1 2025-04-03 17:35:58 +01:00
e0f07be017 fix: init command detecting laravel 2025-04-03 17:23:39 +01:00
42e1b9f17f release: v3.8.0 2025-03-30 18:49:10 +01:00
0171617c1d chore: adjusts to new types on arch 2025-03-30 18:42:00 +01:00
2e11e9e65d docs: adjusts readme 2025-03-29 18:23:23 +00:00
4969526ef2 chore: bumps paratest 2025-03-29 17:57:53 +00:00
d7b1c36fdd Merge pull request #1341 from nuernbergerA/phpunit-overrides
chore: Sync overrides
2025-03-29 17:52:57 +00:00
003fc96e8f release: 3.7.5 2025-03-29 17:48:00 +00:00
f68d11ccae chore: bumps dependencies 2025-03-29 17:44:06 +00:00
e46d499384 add tests for snapshots external to the tests directory 2025-03-19 16:33:44 +01:00
490f321a0d fix: normalize snapshot paths for files outside tests directory 2025-03-19 15:59:45 +01:00
174645caa2 throw exception instead of using Panic 2025-03-18 14:49:32 +01:00
ed70c9dc2b refactor: type adjustments 2025-03-14 22:40:39 +00:00
157a753d87 Update Pest.php.stub 2025-03-12 18:35:57 +00:00
AFS
2c3a53f6cd Remove reset of message
Reset approach was not the right one.
2025-02-25 15:44:32 +01:00
AFS
0bdaef29e9 Fix lingering }
Remove remaining } in toMatchObject.
2025-02-25 15:41:17 +01:00
AFS
1ad30a97b3 toMatchArray/Object wrong field fix
The functions toMatchArray and toMatchObject indicate that the wrong field is mismatching from the second loop on because the 'message' is overwritten and taken into the following loop. This patch creates a $second_message for the second test (value test) to keep the error message correct.
2025-02-25 15:34:37 +01:00
a5317c5640 fix: add ignoring clause for App\Features\Concerns on Laravel Preset 2025-02-08 00:07:21 +07:00
66ceb64faa Updates tests 2025-02-03 13:36:47 +00:00
fa4098db8d Bumps dependencies 2025-02-03 13:30:45 +00:00
4a987d3d5c release: 3.7.4 2025-01-23 14:03:29 +00:00
4079a08f5f feat: adds --compact to coverage 2025-01-23 13:59:51 +00:00
e4aab77a34 release: 3.7.3 2025-01-23 12:51:02 +00:00
50ff347b59 Pass description into describe call 2025-01-20 17:40:18 -05:00
b5b8fab09b Fix an issue where beforeEach/afterEach functions were called on other describe blocks with the same name 2025-01-20 17:36:22 -05:00
c4c9e915f4 cs 2025-01-20 09:50:36 +01:00
e834527db2 Update JunitXmlLogger.php
https://github.com/sebastianbergmann/phpunit/issues/6098
2025-01-20 09:39:10 +01:00
23f130b0f9 Update JunitXmlLogger.php
from https://github.com/sebastianbergmann/phpunit/issues/5771
c722fb2599
2025-01-20 09:38:24 +01:00
0cb8c42497 sync missing listener 2025-01-20 09:36:48 +01:00
fe4b5e5e1f sync change 2025-01-20 09:35:44 +01:00
8ee9d66d80 sync cs 2025-01-20 09:34:55 +01:00
7760d945bb sync latest changes 2025-01-20 09:34:23 +01:00
709ecb1ba2 chore: adjusts tests 2025-01-19 17:35:09 +00:00
6afb36519d release: 3.7.2 2025-01-19 17:16:25 +00:00
150bb9478d docs: adjusts sponsors 2025-01-08 01:09:20 +00:00
bf3178473d release: 3.7.1 2024-12-12 11:52:01 +00:00
d2eb94d723 chore: bumps phpunit and paratest 2024-12-12 11:50:43 +00:00
9688b83a3d release: 3.7.0 2024-12-10 11:54:49 +00:00
675372c794 chore: fixes types 2024-12-10 11:54:42 +00:00
c18636b3d5 chore: adds phpunit 11.5.0 support 2024-12-10 11:53:59 +00:00
1ac594bdf0 Add Attributes to Laravel preset 2024-12-06 16:07:59 +05:00
145294a4a3 chore: style 2024-12-01 23:55:15 +00:00
c2cabaeae6 chore: fixes test suite 2024-12-01 23:16:34 +00:00
918a8fc169 release: 3.6.0 2024-12-01 22:46:00 +00:00
5d32dd0641 feat: option to coverage 2024-12-01 22:45:31 +00:00
982353fb38 release: 3.5.2 2024-12-01 21:28:14 +00:00
2eefa8b88d chore: adds phpunit 11.4.4 support 2024-12-01 21:26:11 +00:00
787d5492ac chore: ignores temporary file 2024-12-01 20:41:34 +00:00
06a0bd9b0b Bumps dependencies 2024-11-21 10:46:27 +00:00
5331b44a18 Allow custom arch expectations 2024-11-20 11:54:36 +01:00
91afc81222 Updates sponsors 2024-11-09 14:20:12 +00:00
179d46ce97 release: v3.5.1 2024-10-31 12:12:45 -04:00
fa2bc1e536 chore: bumps dependencies 2024-10-31 12:12:37 -04:00
eaeb133c77 release: v3.5.0 2024-10-22 15:33:27 +01:00
cf57ea1f94 Merge pull request #1295 from jshayes/nested-describe
Support for nested describe blocks
2024-10-22 13:41:38 +01:00
0b7f4f2384 Merge pull request #1301 from faissaloux/update-tests-badge
Update tests badge to v3
2024-10-20 12:48:07 +01:00
2903a7e621 release: v3.4.2 2024-10-20 12:47:25 +01:00
b8964375c7 release: v3.4.1 2024-10-20 12:43:35 +01:00
bdcb883829 chore: bumps phpunit version 2024-10-20 12:43:28 +01:00
8a7e7f39ef update tests badge to v3 2024-10-17 02:33:25 +01:00
53c94600cb fix: handle -c flag same as --configuration 2024-10-16 22:19:08 +02:00
67f217852c chore: uses stable version of collision and termwind 2024-10-15 17:17:09 +01:00
1bad148487 release: v3.4.0 2024-10-15 15:13:19 +01:00
e24f137b8e fix: deprecation 2024-10-15 15:09:26 +01:00
6d9189f3f5 feat: php 8.4 support 2024-10-15 15:04:30 +01:00
6968094e2b Add tests 2024-10-13 10:39:17 -04:00
9510d4a2f9 Change array type hint 2024-10-13 09:54:19 -04:00
cd2eb3504b Add helper to get last element of array 2024-10-13 09:47:54 -04:00
7c639cdbbd Add more tests 2024-10-13 00:41:04 -04:00
1513ede73b release: v3.3.2 2024-10-12 12:36:44 +01:00
8c65197881 chore: bumps depndencies 2024-10-12 12:33:57 +01:00
a6cd83665c Execute all parent beforeEach and afterEach functions for each test 2024-10-11 23:51:18 -04:00
0c57142c03 Fix an issue where a describe block will prevent a beforeEach call from executing 2024-10-11 21:24:08 -04:00
3f65af9fdf Merge pull request #1292 from olivernybroe/policy-suffix
Add Policy suffix to policies
2024-10-11 09:34:05 +01:00
42d89814e3 Merge pull request #1293 from AbdellahBoutmad/dir
modify test command in contrbuting.md
2024-10-11 09:33:41 +01:00
1e3156a5b6 release: v3.3.1 2024-10-11 09:31:24 +01:00
97713c0832 chore: bumps dependencies 2024-10-11 09:31:16 +01:00
62b0e3c9df modify test command in contrbuting.md 2024-10-10 12:34:04 +01:00
647de2f1cf Add Policy suffix to policies 2024-10-10 08:08:10 +02:00
0a7bff0d24 release: v3.3.0 2024-10-06 19:25:27 +01:00
7618434580 chore: uses phpunit v11.4.0 2024-10-06 19:25:20 +01:00
dd7d150caa Add toNotIncludeSuspiciousCharacters() expectation 2024-10-04 13:44:10 +02:00
1e0bb88b73 release: v3.2.5 2024-10-01 11:55:18 +01:00
83b76d7c2e chore: bumps dependencies 2024-10-01 11:51:26 +01:00
5a870b3940 chore: style changes 2024-10-01 11:51:16 +01:00
1115c64186 chore: style changes 2024-10-01 11:48:14 +01:00
e38a271ca2 Merge pull request #1279 from midnite81/bug/declare-strict-types-with-comments-above
Strict types expectation allows for comments above declaration
2024-09-29 19:12:06 +01:00
43703ab40a Merge pull request #1280 from CamKem/fix/middleware-method-preset
Fix: Add middleware to the allowable public methods for Laravel Preset
2024-09-29 19:11:40 +01:00
86452765a4 fix: add middleware to the allowable public methods on the laravel preset 2024-09-29 16:07:39 +10:00
b8a1b7e5cc Add tests for strict types expectation
Introduced new test cases to ensure strict type declaration handling. Files with and without strict types are tested, including scenarios with comments preceding the declaration. Updated the regex in `Expectation.php` to accommodate comments and whitespaces before the `declare(strict_types=1)` statement.
2024-09-28 17:31:33 +01:00
5fe79d9c18 release: v3.2.4 2024-09-26 23:53:39 +01:00
2744da4292 Merge pull request #1277 from MuhammedAlkhudiry/ignore-handler-in-laravel-preset
ignore App\Exceptions\Handler.php in arch laravel preset
2024-09-26 23:47:26 +01:00
87f4e5e7b3 fix 2024-09-26 16:44:30 +03:00
bb3decf3cc ignore App\Exceptions\Handler.php in arch laravel preset 2024-09-26 16:41:43 +03:00
4e2987d438 release: v3.2.3 2024-09-25 16:19:39 +01:00
a25158bce8 Merge pull request #1275 from jeremynikolic/laravel-presets-ignore-concerns
feat: add ignoring of Concerns folder inside App\Enums and App\Features
2024-09-25 16:16:26 +01:00
49e77b1d4c feat: add ignoring of Concerns folder inside App\Enums and App\Features 2024-09-25 17:12:42 +02:00
989e43d1a0 release: v3.2.2 2024-09-24 10:23:43 +01:00
7cd42aafd8 fix: auto-complete on presets 2024-09-24 10:23:32 +01:00
48a1de273f release: v3.2.1 2024-09-23 14:09:55 +01:00
970e16e949 Ignores 2024-09-23 14:08:30 +01:00
432ff221c6 fix: missing != and !== on new toUseStrictEquality arch expectation 2024-09-23 14:08:21 +01:00
a55da85dd2 release: v3.2.0 2024-09-23 13:14:03 +01:00
f291cd1603 chore: bumps dependencies 2024-09-23 13:11:49 +01:00
5de0c2254a release: v3.1.0 2024-09-19 23:39:07 +01:00
b98ce0ced3 feat: adds mutates 2024-09-19 23:32:28 +01:00
28772c2609 chore: dont run integration tests yet on php 8.4 2024-09-19 13:42:01 +01:00
452ffaf8df chore: fixes windows build 2024-09-19 13:38:35 +01:00
e8338405b5 chore: tests against PHP 8.4 2024-09-19 13:36:41 +01:00
1b014e4b18 release: v3.0.8 2024-09-19 13:04:42 +01:00
034715e8b1 Merge pull request #1266 from julien-boudry/3.x
Fix #1265 -  issue parameter cannot be int (one done, pr, todo, wip)
2024-09-19 12:53:34 +01:00
09eff785c4 release: v3.0.7 2024-09-19 12:29:38 +01:00
22cc7805d7 chore: bumps dependencies 2024-09-19 12:29:38 +01:00
669dc0da71 Fix #1265 - issue parameter cannot be int (one done, pr, todo, wip) 2024-09-19 09:49:36 +00:00
689da4ed4e Merge pull request #1254 from pestphp/bugfix/jira-url
fix: update assignee URL for Jira
2024-09-18 21:48:09 +01:00
92bc1decd9 Add tests for toBeSlug method 2024-09-16 13:41:13 +01:00
e3bfcbe5f1 Add slugify method 2024-09-16 13:36:34 +01:00
2f15861b0d fix: update assignee URL for Jira 2024-09-16 12:18:21 +01:00
ba7eb70a5d Remove unnecessary property 2024-09-12 13:11:12 +03:00
74ff3b8cd9 Update src/PendingCalls/TestCall.php
Co-authored-by: Owen Voke <development@voke.dev>
2024-09-12 13:09:57 +03:00
ab0b4a1b4e Update src/PendingCalls/TestCall.php
Co-authored-by: Owen Voke <development@voke.dev>
2024-09-12 13:09:45 +03:00
169b76458e make the name of the method plural 2024-09-12 11:15:30 +03:00
668685498f Fix phpdoc type-hints 2024-09-12 03:51:20 +03:00
bab193e7e1 Fix property type 2024-09-12 03:24:36 +03:00
f720be862e Add reference method 2024-09-12 02:45:37 +03:00
0d50d35b5e release: v3.0.6 2024-09-11 18:59:43 +01:00
ce61ced8e1 Merge pull request #1237 from smirok/teamcity-fix-for-tests-with-dataset
fix: unify the `locationHint` prefix and prettify both `locationHint` and `name` parameters for testing with datasets
2024-09-11 18:51:04 +01:00
7227d24611 fix: unify the locationHint prefix and prettify both locationHint and name parameters for testing with datasets 2024-09-11 16:42:06 +02:00
45f16484d5 Merge pull request #1235 from pestphp/3.x_herd_fix
Fixes parallel mutation testing when using Laravel Herd
2024-09-11 15:13:49 +01:00
b16e8650da Fixes parallel mutation testing when using Laravel Herd. 2024-09-11 15:11:47 +01:00
c2f30e0148 Fixes parallel mutation testing when using Laravel Herd. 2024-09-11 15:04:44 +01:00
47ce45de56 release: v3.0.4 2024-09-11 00:48:29 +01:00
32881774d2 fix: global afterEach being called twice 2024-09-11 00:40:41 +01:00
ea72461f1b release: v3.0.3 2024-09-10 22:29:09 +01:00
49f15521e0 fix: printer method name 2024-09-10 22:29:01 +01:00
95c5394b66 Bumps dependencies 2024-09-10 16:59:38 +01:00
183 changed files with 3320 additions and 667 deletions

View File

@ -24,15 +24,19 @@ jobs:
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: 8.2 php-version: 8.3
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets
- name: Install Dependencies - name: Install Dependencies
run: composer update --prefer-stable --no-interaction --no-progress --ansi run: composer update --prefer-stable --no-interaction --no-progress --ansi
# - name: Type Check - name: Profanity Check
# run: composer test:type:check run: composer test:profanity
- name: Type Check
run: composer test:type:check
- name: Type Coverage - name: Type Coverage
run: composer test:type:coverage run: composer test:type:coverage

View File

@ -12,10 +12,10 @@ jobs:
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.0'] symfony: ['7.3']
php: ['8.2', '8.3'] php: ['8.3', '8.4']
dependency_version: [prefer-lowest, 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 }}
@ -29,6 +29,7 @@ jobs:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
extensions: sockets
- name: Setup Problem Matches - name: Setup Problem Matches
run: | run: |
@ -36,7 +37,8 @@ jobs:
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install PHP dependencies - name: Install PHP dependencies
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:~${{ matrix.symfony }}" shell: bash
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests - name: Unit Tests
run: composer test:unit run: composer test:unit

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ coverage.xml
*.swp *.swp
*.swo *.swo
.vscode/ .vscode/
.STREAM.md

View File

@ -42,7 +42,7 @@ composer test
Check types: Check types:
```bash ```bash
composer test:types composer test:type:check
``` ```
Unit tests: Unit tests:

View File

@ -1,7 +1,7 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v3/banner.png" width="600" alt="PEST"> <img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<p align="center"> <p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=2.x&label=Tests%202.x"></a> <a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://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>
@ -10,34 +10,43 @@
------ ------
> Pest v3 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest3-now-available)**. > Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP. **Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
- Explore our docs at **[pestphp.com »](https://pestphp.com)** - Explore our docs at **[pestphp.com »](https://pestphp.com)**
- Follow us on Twitter at **[@pestphp »](https://twitter.com/pestphp)** - Follow the creator Nuno Maduro:
- Join us at **[discord.gg/kaHY6p54JH »](https://discord.gg/kaHY6p54JH)** or **[t.me/+kYH5G4d5MV83ODk0 »](https://t.me/+kYH5G4d5MV83ODk0)** - YouTube: **[youtube.com/@nunomaduro](https://www.youtube.com/@nunomaduro)** — Videos every weekday
- Twitch: **[twitch.tv/enunomaduro](https://www.twitch.tv/enunomaduro)** — Streams (almost) every weekday
- Twitter / X: **[x.com/enunomaduro](https://x.com/enunomaduro)**
- LinkedIn: **[linkedin.com/in/nunomaduro](https://www.linkedin.com/in/nunomaduro)**
- Instagram: **[instagram.com/enunomaduro](https://www.instagram.com/enunomaduro)**
- Tiktok: **[tiktok.com/@enunomaduro](https://www.tiktok.com/@enunomaduro)**
## Sponsors ## Sponsors
We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**. We cannot thank our sponsors enough for their incredible support in funding Pest's development. Their contributions have been instrumental in making Pest the best it can be. For those who are interested in becoming a sponsor, please visit Nuno Maduro's Sponsor page at **[github.com/sponsors/nunomaduro](https://github.com/sponsors/nunomaduro)**.
### Platinum Sponsors ### Platinum Sponsors
- **[LaraJobs](https://larajobs.com)** - **[Laracasts](https://laracasts.com/?ref=pestphp)**
- **[Brokerchooser](https://brokerchooser.com)**
- **[Forge](https://forge.laravel.com)** ### Gold Sponsors
- **[Spatie](https://spatie.be)**
- **[Worksome](https://www.worksome.com/)** - **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)**
- **[NativePHP](https://nativephp.com/mobile?ref=pestphp.com)**
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
### Premium Sponsors ### Premium Sponsors
- [Akaunting](https://akaunting.com/?ref=pestphp) - [Akaunting](https://akaunting.com/?ref=pestphp)
- [Codecourse](https://codecourse.com/?ref=pestphp)
- [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp) - [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp)
- [Laracasts](https://laracasts.com/?ref=pestphp)
- [Localazy](https://localazy.com/?ref=pestphp) - [Localazy](https://localazy.com/?ref=pestphp)
- [Forge](https://forge.laravel.com/?ref=pestphp)
- [Route4Me](https://www.route4me.com/?ref=pestphp) - [Route4Me](https://www.route4me.com/?ref=pestphp)
- [Spatie](https://spatie.be/?ref=pestphp)
- [Worksome](https://www.worksome.com/?ref=pestphp)
- [Zapiet](https://www.zapiet.com/?ref=pestphp) - [Zapiet](https://www.zapiet.com/?ref=pestphp)
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

@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done: When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v2 you should use the `2.x` branch instead.** > **For Pest v3 you should use the `3.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 3.x` - Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...3.x](https://github.com/pestphp/pest/compare/{latest_version}...3.x) - On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
- Update the version number in [src/Pest.php](src/Pest.php) - Update the version number in [src/Pest.php](src/Pest.php)
- Run the tests locally using: `composer test` - Run the tests locally using: `composer test`
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"` - Commit the Pest file with the message: `git commit -m "release: vX.X.X"`

View File

@ -1,5 +1,7 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php declare(strict_types=1); <?php
declare(strict_types=1);
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
@ -37,7 +39,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
if (str_contains($value, '--test-directory=')) { if (str_contains($value, '--test-directory=')) {
unset($arguments[$key]); unset($arguments[$key]);
} else if ($value === '--test-directory') { } elseif ($value === '--test-directory') {
unset($arguments[$key]); unset($arguments[$key]);
if (isset($arguments[$key + 1])) { if (isset($arguments[$key + 1])) {
@ -62,7 +64,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
if (str_contains($value, '--assignee=')) { if (str_contains($value, '--assignee=')) {
unset($arguments[$key]); unset($arguments[$key]);
} else if ($value === '--assignee') { } elseif ($value === '--assignee') {
unset($arguments[$key]); unset($arguments[$key]);
if (isset($arguments[$key + 1])) { if (isset($arguments[$key + 1])) {
@ -72,7 +74,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
if (str_contains($value, '--issue=')) { if (str_contains($value, '--issue=')) {
unset($arguments[$key]); unset($arguments[$key]);
} else if ($value === '--issue') { } elseif ($value === '--issue') {
unset($arguments[$key]); unset($arguments[$key]);
if (isset($arguments[$key + 1])) { if (isset($arguments[$key + 1])) {
@ -82,7 +84,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
if (str_contains($value, '--ticket=')) { if (str_contains($value, '--ticket=')) {
unset($arguments[$key]); unset($arguments[$key]);
} else if ($value === '--ticket') { } elseif ($value === '--ticket') {
unset($arguments[$key]); unset($arguments[$key]);
if (isset($arguments[$key + 1])) { if (isset($arguments[$key + 1])) {
@ -92,7 +94,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
if (str_contains($value, '--pr=')) { if (str_contains($value, '--pr=')) {
unset($arguments[$key]); unset($arguments[$key]);
} else if ($value === '--pr') { } elseif ($value === '--pr') {
unset($arguments[$key]); unset($arguments[$key]);
if (isset($arguments[$key + 1])) { if (isset($arguments[$key + 1])) {
@ -102,7 +104,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
if (str_contains($value, '--pull-request=')) { if (str_contains($value, '--pull-request=')) {
unset($arguments[$key]); unset($arguments[$key]);
} else if ($value === '--pull-request') { } elseif ($value === '--pull-request') {
unset($arguments[$key]); unset($arguments[$key]);
if (isset($arguments[$key + 1])) { if (isset($arguments[$key + 1])) {
@ -117,7 +119,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
} }
} }
// Used when Pest is required using composer. // Used when Pest is required using composer.
$vendorPath = dirname(__DIR__, 4).'/vendor/autoload.php'; $vendorPath = dirname(__DIR__, 4).'/vendor/autoload.php';
@ -134,7 +135,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(
$rootPath, $rootPath,
@ -146,11 +147,11 @@ use Symfony\Component\Console\Output\ConsoleOutput;
} }
if ($todo) { if ($todo) {
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter);
} }
if ($notes) { if ($notes) {
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter()); $testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
} }
if ($assignee = $input->getParameterOption('--assignee')) { if ($assignee = $input->getParameterOption('--assignee')) {

View File

@ -32,10 +32,13 @@ $bootPest = (static function (): void {
'status-file:', 'status-file:',
'progress-file:', 'progress-file:',
'unexpected-output-file:', 'unexpected-output-file:',
'testresult-file:', 'test-result-file:',
'result-cache-file:',
'teamcity-file:', 'teamcity-file:',
'testdox-file:', 'testdox-file:',
'testdox-color', 'testdox-color',
'testdox-columns:',
'testdox-summary',
'phpunit-argv:', 'phpunit-argv:',
]); ]);
@ -61,7 +64,8 @@ $bootPest = (static function (): void {
assert(isset($getopt['progress-file']) && is_string($getopt['progress-file'])); assert(isset($getopt['progress-file']) && is_string($getopt['progress-file']));
assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file'])); assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file']));
assert(isset($getopt['testresult-file']) && is_string($getopt['testresult-file'])); assert(isset($getopt['test-result-file']) && is_string($getopt['test-result-file']));
assert(! isset($getopt['result-cache-file']) || is_string($getopt['result-cache-file']));
assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file'])); assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file']));
assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file'])); assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file']));
@ -77,11 +81,12 @@ $bootPest = (static function (): void {
$phpunitArgv, $phpunitArgv,
$getopt['progress-file'], $getopt['progress-file'],
$getopt['unexpected-output-file'], $getopt['unexpected-output-file'],
$getopt['testresult-file'], $getopt['test-result-file'],
$getopt['result-cache-file'] ?? null,
$getopt['teamcity-file'] ?? null, $getopt['teamcity-file'] ?? null,
$getopt['testdox-file'] ?? null, $getopt['testdox-file'] ?? null,
isset($getopt['testdox-color']), isset($getopt['testdox-color']),
$getopt['testdox-columns'] ?? null, (int) $getopt['testdox-columns'] ?? null,
); );
while (true) { while (true) {

View File

@ -17,18 +17,21 @@
} }
], ],
"require": { "require": {
"php": "^8.2.0", "php": "^8.3.0",
"brianium/paratest": "^7.5.4", "brianium/paratest": "^7.11.2",
"nunomaduro/collision": "^8.4.0", "nunomaduro/collision": "^8.8.2",
"nunomaduro/termwind": "^2.1.0", "nunomaduro/termwind": "^2.3.1",
"pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^3.0.0", "pestphp/pest-plugin-arch": "^4.0.0",
"pestphp/pest-plugin-mutate": "^3.0.2", "pestphp/pest-plugin-mutate": "^4.0.1",
"phpunit/phpunit": "^11.3.4" "pestphp/pest-plugin-profanity": "^4.0.1",
"phpunit/phpunit": "^12.3.6",
"symfony/process": "^7.3.0"
}, },
"conflict": { "conflict": {
"phpunit/phpunit": ">11.3.4", "filp/whoops": "<2.18.3",
"sebastian/exporter": "<6.0.0", "phpunit/phpunit": ">12.3.6",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
"autoload": { "autoload": {
@ -52,9 +55,10 @@
] ]
}, },
"require-dev": { "require-dev": {
"pestphp/pest-dev-tools": "^3.0.0", "pestphp/pest-dev-tools": "^4.0.0",
"pestphp/pest-plugin-type-coverage": "^3.0.0", "pestphp/pest-plugin-browser": "^4.0.2",
"symfony/process": "^7.1.3" "pestphp/pest-plugin-type-coverage": "^4.0.2",
"psy/psysh": "^0.12.10"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
@ -70,16 +74,17 @@
], ],
"scripts": { "scripts": {
"refacto": "rector", "refacto": "rector",
"lint": "pint", "lint": "pint --parallel",
"test:refacto": "rector --dry-run", "test:refacto": "rector --dry-run",
"test:lint": "pint --test", "test:lint": "pint --parallel --test",
"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",
"test:unit": "php bin/pest --colors=always --exclude-group=integration --compact", "test:unit": "php bin/pest --exclude-group=integration --compact",
"test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml", "test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=3", "test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --colors=always --group=integration -v", "test:integration": "php bin/pest --group=integration -v",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [ "test": [
"@test:refacto", "@test:refacto",
"@test:lint", "@test:lint",
@ -110,6 +115,7 @@
"Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Snapshot",
"Pest\\Plugins\\Verbose", "Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version", "Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard",
"Pest\\Plugins\\Parallel" "Pest\\Plugins\\Parallel"
] ]
}, },

View File

@ -52,6 +52,8 @@ use PHPUnit\Util\Filter;
use PHPUnit\Util\ThrowableToStringMapper; use PHPUnit\Util\ThrowableToStringMapper;
/** /**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @internal This class is not covered by the backward compatibility promise for PHPUnit * @internal This class is not covered by the backward compatibility promise for PHPUnit
*/ */
final readonly class ThrowableBuilder final readonly class ThrowableBuilder
@ -68,7 +70,7 @@ final readonly class ThrowableBuilder
$previous = self::from($previous); $previous = self::from($previous);
} }
$trace = Filter::getFilteredStacktrace($t); $trace = Filter::stackTraceFromThrowableAsString($t);
if ($t instanceof RenderableOnCollisionEditor && $frame = $t->toCollisionEditor()) { if ($t instanceof RenderableOnCollisionEditor && $frame = $t->toCollisionEditor()) {
$file = $frame->getFile(); $file = $frame->getFile();
@ -82,7 +84,7 @@ final readonly class ThrowableBuilder
$t->getMessage(), $t->getMessage(),
ThrowableToStringMapper::map($t), ThrowableToStringMapper::map($t),
$trace, $trace,
$previous $previous,
); );
} }
} }

View File

@ -27,6 +27,7 @@ use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\MarkedIncomplete; use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\PreparationStarted; use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\Started; use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\UnknownSubscriberTypeException; use PHPUnit\Event\UnknownSubscriberTypeException;
@ -41,6 +42,8 @@ use function str_replace;
use function trim; use function trim;
/** /**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @internal This class is not covered by the backward compatibility promise for PHPUnit * @internal This class is not covered by the backward compatibility promise for PHPUnit
*/ */
final class JunitXmlLogger final class JunitXmlLogger
@ -59,32 +62,32 @@ final class JunitXmlLogger
private array $testSuites = []; private array $testSuites = [];
/** /**
* @psalm-var array<int,int> * @var array<int,int>
*/ */
private array $testSuiteTests = [0]; private array $testSuiteTests = [0];
/** /**
* @psalm-var array<int,int> * @var array<int,int>
*/ */
private array $testSuiteAssertions = [0]; private array $testSuiteAssertions = [0];
/** /**
* @psalm-var array<int,int> * @var array<int,int>
*/ */
private array $testSuiteErrors = [0]; private array $testSuiteErrors = [0];
/** /**
* @psalm-var array<int,int> * @var array<int,int>
*/ */
private array $testSuiteFailures = [0]; private array $testSuiteFailures = [0];
/** /**
* @psalm-var array<int,int> * @var array<int,int>
*/ */
private array $testSuiteSkipped = [0]; private array $testSuiteSkipped = [0];
/** /**
* @psalm-var array<int,int> * @var array<int,int>
*/ */
private array $testSuiteTimes = [0]; private array $testSuiteTimes = [0];
@ -113,7 +116,7 @@ final class JunitXmlLogger
public function flush(): void public function flush(): void
{ {
$this->printer->print($this->document->saveXML()); $this->printer->print($this->document->saveXML() ?: '');
$this->printer->flush(); $this->printer->flush();
} }
@ -195,28 +198,34 @@ final class JunitXmlLogger
$this->createTestCase($event); $this->createTestCase($event);
} }
/**
* @throws InvalidArgumentException
*/
public function testPreparationFailed(): void public function testPreparationFailed(): void
{ {
$this->preparationFailed = true; $this->preparationFailed = true;
} }
/**
* @throws InvalidArgumentException
*/
public function testPrepared(): void public function testPrepared(): void
{ {
$this->prepared = true; $this->prepared = true;
} }
public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $event): void
{
assert($this->currentTestCase !== null);
$systemOut = $this->document->createElement(
'system-out',
Xml::prepareString($event->output()),
);
$this->currentTestCase->appendChild($systemOut);
}
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function testFinished(Finished $event): void public function testFinished(Finished $event): void
{ {
if ($this->preparationFailed) { if (! $this->prepared || $this->preparationFailed) {
return; return;
} }
@ -305,9 +314,11 @@ final class JunitXmlLogger
new TestPreparationStartedSubscriber($this), new TestPreparationStartedSubscriber($this),
new TestPreparationFailedSubscriber($this), new TestPreparationFailedSubscriber($this),
new TestPreparedSubscriber($this), new TestPreparedSubscriber($this),
new TestPrintedUnexpectedOutputSubscriber($this),
new TestFinishedSubscriber($this), new TestFinishedSubscriber($this),
new TestErroredSubscriber($this), new TestErroredSubscriber($this),
new TestFailedSubscriber($this), new TestFailedSubscriber($this),
new TestMarkedIncompleteSubscriber($this),
new TestSkippedSubscriber($this), new TestSkippedSubscriber($this),
new TestRunnerExecutionFinishedSubscriber($this), new TestRunnerExecutionFinishedSubscriber($this),
); );
@ -431,7 +442,7 @@ final class JunitXmlLogger
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
* *
* @psalm-assert !null $this->currentTestCase * @phpstan-assert !null $this->currentTestCase
*/ */
private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void
{ {
@ -446,7 +457,7 @@ final class JunitXmlLogger
if ($test->isTestMethod()) { if ($test->isTestMethod()) {
assert($test instanceof TestMethod); assert($test instanceof TestMethod);
//$testCase->setAttribute('line', (string) $test->line()); // pest-removed // $testCase->setAttribute('line', (string) $test->line()); // pest-removed
$className = $this->converter->getTrimmedTestClassName($test); // pest-added $className = $this->converter->getTrimmedTestClassName($test); // pest-added
$testCase->setAttribute('class', $className); // pest-changed $testCase->setAttribute('class', $className); // pest-changed
$testCase->setAttribute('classname', str_replace('\\', '.', $className)); // pest-changed $testCase->setAttribute('classname', str_replace('\\', '.', $className)); // pest-changed

View File

@ -99,7 +99,9 @@ abstract class NameFilterIterator extends RecursiveFilterIterator
} }
if ($test instanceof HasPrintableTestCaseName) { if ($test instanceof HasPrintableTestCaseName) {
$name = $test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName(); $name = trim(
$test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName().$test->dataSetAsString()
);
} else { } else {
$name = $test::class.'::'.$test->nameWithDataSet(); $name = $test::class.'::'.$test->nameWithDataSet();
} }

View File

@ -46,9 +46,10 @@ declare(strict_types=1);
namespace PHPUnit\Runner\ResultCache; namespace PHPUnit\Runner\ResultCache;
use const DIRECTORY_SEPARATOR; use const DIRECTORY_SEPARATOR;
use const LOCK_EX;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\Runner\DirectoryCannotBeCreatedException; use PHPUnit\Runner\DirectoryDoesNotExistException;
use PHPUnit\Runner\Exception; use PHPUnit\Runner\Exception;
use PHPUnit\Util\Filesystem; use PHPUnit\Util\Filesystem;
@ -65,24 +66,23 @@ use function json_encode;
use function Pest\version; use function Pest\version;
/** /**
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
*
* @internal This class is not covered by the backward compatibility promise for PHPUnit * @internal This class is not covered by the backward compatibility promise for PHPUnit
*/ */
final class DefaultResultCache implements ResultCache final class DefaultResultCache implements ResultCache
{ {
/** private const string DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
* @var string
*/
private const DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
private readonly string $cacheFilename; private readonly string $cacheFilename;
/** /**
* @psalm-var array<string, TestStatus> * @var array<string, TestStatus>
*/ */
private array $defects = []; private array $defects = [];
/** /**
* @psalm-var array<string, float> * @var array<string, float>
*/ */
private array $times = []; private array $times = [];
@ -95,28 +95,39 @@ final class DefaultResultCache implements ResultCache
$this->cacheFilename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_RESULT_CACHE_FILENAME; $this->cacheFilename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_RESULT_CACHE_FILENAME;
} }
public function setStatus(string $id, TestStatus $status): void public function setStatus(ResultCacheId $id, TestStatus $status): void
{ {
if ($status->isSuccess()) { if ($status->isSuccess()) {
return; return;
} }
$this->defects[$id] = $status; $this->defects[$id->asString()] = $status;
} }
public function status(string $id): TestStatus public function status(ResultCacheId $id): TestStatus
{ {
return $this->defects[$id] ?? TestStatus::unknown(); return $this->defects[$id->asString()] ?? TestStatus::unknown();
} }
public function setTime(string $id, float $time): void public function setTime(ResultCacheId $id, float $time): void
{ {
$this->times[$id] = $time; $this->times[$id->asString()] = $time;
} }
public function time(string $id): float public function time(ResultCacheId $id): float
{ {
return $this->times[$id] ?? 0.0; return $this->times[$id->asString()] ?? 0.0;
}
public function mergeWith(self $other): void
{
foreach ($other->defects as $id => $defect) {
$this->defects[$id] = $defect;
}
foreach ($other->times as $id => $time) {
$this->times[$id] = $time;
}
} }
public function load(): void public function load(): void
@ -165,7 +176,7 @@ final class DefaultResultCache implements ResultCache
public function persist(): void public function persist(): void
{ {
if (! Filesystem::createDirectory(dirname($this->cacheFilename))) { if (! Filesystem::createDirectory(dirname($this->cacheFilename))) {
throw new DirectoryCannotBeCreatedException($this->cacheFilename); throw new DirectoryDoesNotExistException(dirname($this->cacheFilename));
} }
$data = [ $data = [

View File

@ -45,6 +45,7 @@ declare(strict_types=1);
namespace PHPUnit\TextUI; namespace PHPUnit\TextUI;
use Pest\Plugins\Only; use Pest\Plugins\Only;
use Pest\Runner\Filter\EnsureTestCaseIsInitiatedFilter;
use PHPUnit\Event; use PHPUnit\Event;
use PHPUnit\Framework\TestSuite; use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\Filter\Factory; use PHPUnit\Runner\Filter\Factory;
@ -66,6 +67,12 @@ final readonly class TestSuiteFilterProcessor
{ {
$factory = new Factory; $factory = new Factory;
// @phpstan-ignore-next-line
(fn () => $this->filters[] = [
'className' => EnsureTestCaseIsInitiatedFilter::class,
'argument' => '',
])->call($factory);
if (! $configuration->hasFilter() && if (! $configuration->hasFilter() &&
! $configuration->hasGroups() && ! $configuration->hasGroups() &&
! $configuration->hasExcludeGroups() && ! $configuration->hasExcludeGroups() &&
@ -73,6 +80,8 @@ final readonly class TestSuiteFilterProcessor
! $configuration->hasTestsCovering() && ! $configuration->hasTestsCovering() &&
! $configuration->hasTestsUsing() && ! $configuration->hasTestsUsing() &&
! Only::isEnabled()) { ! Only::isEnabled()) {
$suite->injectFilter($factory);
return; return;
} }

199
phpstan-baseline.neon Normal file
View File

@ -0,0 +1,199 @@
parameters:
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\.$#'
identifier: trait.unused
count: 1
path: src/Concerns/Expectable.php
-
message: '#^Trait Pest\\Concerns\\Logging\\WritesToConsole is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
path: src/Concerns/Logging/WritesToConsole.php
-
message: '#^Trait Pest\\Concerns\\Testable is used zero times and is not analysed\.$#'
identifier: trait.unused
count: 1
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\>\.$#'
identifier: return.type
count: 1
path: src/Expectation.php
-
message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$each contains generic class Pest\\Expectations\\EachExpectation but does not specify its types\: TValue$#'
identifier: missingType.generics
count: 1
path: src/Expectation.php
-
message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$not contains generic class Pest\\Expectations\\OppositeExpectation but does not specify its types\: TValue$#'
identifier: missingType.generics
count: 1
path: src/Expectation.php
-
message: '#^Parameter \#2 \$newScope of method Closure\:\:bindTo\(\) expects ''static''\|class\-string\|object\|null, string given\.$#'
identifier: argument.type
count: 1
path: src/Expectation.php
-
message: '#^Function expect\(\) should return Pest\\Expectation\<TValue\|null\> but returns Pest\\Expectation\<TValue\|null\>\.$#'
identifier: return.type
count: 1
path: src/Functions.php
-
message: '#^Parameter \#1 \$argv of method PHPUnit\\TextUI\\Application\:\:run\(\) expects list\<string\>, array\<int, string\> given\.$#'
identifier: argument.type
count: 1
path: src/Kernel.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:__toString\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Mixins/Expectation.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:toArray\(\)\.$#'
identifier: method.notFound
count: 4
path: src/Mixins/Expectation.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:toSnapshot\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Mixins/Expectation.php
-
message: '#^Call to an undefined method object&TValue of mixed\:\:toString\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Mixins/Expectation.php
-
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
identifier: staticMethod.alreadyNarrowedType
count: 2
path: src/Mixins/Expectation.php
-
message: '#^PHPDoc tag @var with type callable\(\)\: bool is not subtype of native type Closure\|null\.$#'
identifier: varTag.nativeType
count: 1
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\.$#'
identifier: argument.type
count: 1
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\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#8 \$testSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\Skipped\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Parameter \#9 \$testMarkedIncompleteEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\<PHPUnit\\Event\\Test\\MarkedIncomplete\>, array given\.$#'
identifier: argument.type
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php
-
message: '#^Property Pest\\Plugins\\Parallel\\Paratest\\WrapperRunner\:\:\$pending \(list\<non\-empty\-string\>\) does not accept array\<int, non\-empty\-string\>\.$#'
identifier: assign.propertyType
count: 1
path: src/Plugins/Parallel/Paratest/WrapperRunner.php

View File

@ -1,23 +1,12 @@
includes: includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon - phpstan-baseline.neon
- vendor/ergebnis/phpstan-rules/rules.neon
- vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
parameters: parameters:
level: max level: 7
paths: paths:
- src - src
checkMissingIterableValueType: true reportUnmatchedIgnoredErrors: false
reportUnmatchedIgnoredErrors: true
ignoreErrors: ignoreErrors:
- "#has a nullable return type declaration.#"
- "#Language construct isset\\(\\) should not be used.#"
- "#is not allowed to extend#"
- "#is concrete, but does not have a Test suffix#"
- "#with a nullable type declaration#"
- "#type mixed is not subtype of native#" - "#type mixed is not subtype of native#"
- "# with null as default value#"
- "#has parameter \\$closure with default value.#"
- "#has parameter \\$description with default value.#"

View File

@ -16,6 +16,7 @@
<testsuites> <testsuites>
<testsuite name="default"> <testsuite name="default">
<directory suffix=".php">./tests</directory> <directory suffix=".php">./tests</directory>
<directory suffix=".php">./tests-external</directory>
<exclude>./tests/.snapshots</exclude> <exclude>./tests/.snapshots</exclude>
<exclude>./tests/.tests</exclude> <exclude>./tests/.tests</exclude>
<exclude>./tests/Fixtures/Inheritance</exclude> <exclude>./tests/Fixtures/Inheritance</exclude>

View File

@ -0,0 +1,22 @@
<div class="mx-2 mb-1">
<p>
<span>Using the <span class="text-yellow font-bold">visit()</span> function requires the Pest Plugin Browser to be installed.</span>
<span class="ml-1 text-yellow font-bold">Run:</span>
</p>
<div>
<span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
</div>
<div>
<span class="text-gray mr-1">- </span>
<span>npm install playwright@latest</span>
</div>
<div>
<span class="text-gray mr-1">- </span>
<span>npx playwright install</span>
</div>
</div>

View File

@ -27,17 +27,21 @@ final class Laravel extends AbstractPreset
->ignoring('App\Enums'); ->ignoring('App\Enums');
$this->expectations[] = expect('App\Enums') $this->expectations[] = expect('App\Enums')
->toBeEnums(); ->toBeEnums()
->ignoring('App\Enums\Concerns');
$this->expectations[] = expect('App\Features') $this->expectations[] = expect('App\Features')
->toBeClasses(); ->toBeClasses()
->ignoring('App\Features\Concerns');
$this->expectations[] = expect('App\Features') $this->expectations[] = expect('App\Features')
->toHaveMethod('resolve'); ->toHaveMethod('resolve')
->ignoring('App\Features\Concerns');
$this->expectations[] = expect('App\Exceptions') $this->expectations[] = expect('App\Exceptions')
->classes() ->classes()
->toImplement('Throwable'); ->toImplement('Throwable')
->ignoring('App\Exceptions\Handler');
$this->expectations[] = expect('App') $this->expectations[] = expect('App')
->not->toImplement(Throwable::class) ->not->toImplement(Throwable::class)
@ -149,7 +153,7 @@ final class Laravel extends AbstractPreset
->toOnlyBeUsedIn('App\Http'); ->toOnlyBeUsedIn('App\Http');
$this->expectations[] = expect('App\Http\Controllers') $this->expectations[] = expect('App\Http\Controllers')
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy']); ->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
$this->expectations[] = expect([ $this->expectations[] = expect([
'dd', 'dd',
@ -159,5 +163,15 @@ final class Laravel extends AbstractPreset
'exit', 'exit',
'ray', 'ray',
])->not->toBeUsed(); ])->not->toBeUsed();
$this->expectations[] = expect('App\Policies')
->classes()
->toHaveSuffix('Policy');
$this->expectations[] = expect('App\Attributes')
->classes()
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute')
->toHaveMethod('resolve');
} }
} }

View File

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace Pest\ArchPresets; namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/** /**
* @internal * @internal
*/ */
@ -89,5 +92,9 @@ 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

@ -32,7 +32,6 @@ final class Security extends AbstractPreset
'create_function', 'create_function',
'unserialize', 'unserialize',
'extract', 'extract',
'parse_str',
'mb_parse_str', 'mb_parse_str',
'dl', 'dl',
'assert', 'assert',

View File

@ -21,6 +21,7 @@ final class Strict extends AbstractPreset
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHaveProtectedMethods(), fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHaveProtectedMethods(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeAbstract(), fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeAbstract(),
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictTypes(), fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictTypes(),
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictEquality(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->toBeFinal(), fn (Expectation $namespace): ArchExpectation => $namespace->classes()->toBeFinal(),
); );

View File

@ -17,7 +17,7 @@ final class BootExcludeList implements Bootstrapper
* *
* @var array<int, non-empty-string> * @var array<int, non-empty-string>
*/ */
private const EXCLUDE_LIST = [ private const array EXCLUDE_LIST = [
'bin', 'bin',
'overrides', 'overrides',
'resources', 'resources',

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Bootstrappers; namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper; use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\FatalException;
use Pest\Support\DatasetInfo; use Pest\Support\DatasetInfo;
use Pest\Support\Str; use Pest\Support\Str;
use Pest\TestSuite; use Pest\TestSuite;
@ -24,7 +25,7 @@ final class BootFiles implements Bootstrapper
* *
* @var array<int, string> * @var array<int, string>
*/ */
private const STRUCTURE = [ private const array STRUCTURE = [
'Expectations', 'Expectations',
'Expectations.php', 'Expectations.php',
'Helpers', 'Helpers',
@ -40,6 +41,10 @@ final class BootFiles implements Bootstrapper
$rootPath = TestSuite::getInstance()->rootPath; $rootPath = TestSuite::getInstance()->rootPath;
$testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory(); $testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory();
if (! is_dir($testsPath)) {
throw new FatalException(sprintf('The test directory [%s] does not exist.', $testsPath));
}
foreach (self::STRUCTURE as $filename) { foreach (self::STRUCTURE as $filename) {
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename); $filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);

View File

@ -15,17 +15,17 @@ final class BootOverrides implements Bootstrapper
/** /**
* The list of files to be overridden. * The list of files to be overridden.
* *
* @var array<string, string> * @var array<int, string>
*/ */
public const FILES = [ public const array FILES = [
'c96b1cb57d7fc8e649f4c13a8abe418c2541bcfab194fb6702b99f777f52ee84' => 'Runner/Filter/NameFilterIterator.php', 'Runner/Filter/NameFilterIterator.php',
'a4a43de01f641c6944ee83d963795a46d32b5206b5ab3bbc6cce76e67190acbf' => 'Runner/ResultCache/DefaultResultCache.php', 'Runner/ResultCache/DefaultResultCache.php',
'd0e81317889ad88c707db4b08a94cadee4c9010d05ff0a759f04e71af5efed89' => 'Runner/TestSuiteLoader.php', 'Runner/TestSuiteLoader.php',
'3bb609b0d3bf6dee8df8d6cd62a3c8ece823c4bb941eaaae39e3cb267171b9d2' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php', 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'8abdad6413329c6fe0d7d44a8b9926e390af32c0b3123f3720bb9c5bbc6fbb7e' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php', 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'43883b7e5811886cf3731c8ed6304d5a77078d9731e1e505abc2da36bde19f3e' => 'TextUI/TestSuiteFilterProcessor.php', 'TextUI/TestSuiteFilterProcessor.php',
'357d5cd7007f8559b26e1b8cdf43bb6fb15b51b79db981779da6f31b7ec39dad' => 'Event/Value/ThrowableBuilder.php', 'Event/Value/ThrowableBuilder.php',
'01974a686eba69b5fbb87a904d936eae2176e39567616898c5b758db71d87a22' => 'Logging/JUnit/JunitXmlLogger.php', 'Logging/JUnit/JunitXmlLogger.php',
]; ];
/** /**

View File

@ -20,7 +20,7 @@ final readonly class BootSubscribers implements Bootstrapper
* *
* @var array<int, class-string<Subscriber>> * @var array<int, class-string<Subscriber>>
*/ */
private const SUBSCRIBERS = [ private const array SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsAvailable::class, Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureKernelDumpIsFlushed::class,

View File

@ -6,10 +6,12 @@ namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -61,8 +63,10 @@ trait Testable
/** /**
* The test's describing, if any. * The test's describing, if any.
*
* @var array<int, string>
*/ */
public ?string $__describing = null; public array $__describing = [];
/** /**
* Whether the test has ran or not. * Whether the test has ran or not.
@ -99,27 +103,6 @@ trait Testable
*/ */
private array $__snapshotChanges = []; private array $__snapshotChanges = [];
/**
* Creates a new Test Case instance.
*/
public function __construct(string $name)
{
parent::__construct($name);
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure($this);
}
}
/** /**
* Resets the test case static properties. * Resets the test case static properties.
*/ */
@ -212,7 +195,11 @@ trait Testable
$beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll); $beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll);
} }
call_user_func(Closure::bind($beforeAll, null, self::class)); try {
call_user_func(Closure::bind($beforeAll, null, self::class));
} catch (Throwable $e) {
Panic::with($e);
}
} }
/** /**
@ -281,6 +268,33 @@ trait Testable
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
} }
/**
* Initialize test case properties from TestSuite.
*/
public function __initializeTestCase(): void
{
// Return if the test case has already been initialized
if (isset($this->__test)) {
return;
}
$name = $this->name();
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure();
$method->setUp($this);
}
}
/** /**
* Gets executed after the Test Case. * Gets executed after the Test Case.
*/ */
@ -298,6 +312,9 @@ trait Testable
parent::tearDown(); parent::tearDown();
TestSuite::getInstance()->test = null; TestSuite::getInstance()->test = null;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$method->tearDown($this);
} }
} }
@ -392,11 +409,12 @@ trait Testable
fn (ReflectionParameter $reflectionParameter): string => $reflectionParameter->getName(), fn (ReflectionParameter $reflectionParameter): string => $reflectionParameter->getName(),
array_filter($testReflection->getParameters(), fn (ReflectionParameter $reflectionParameter): bool => ! $reflectionParameter->isOptional()), array_filter($testReflection->getParameters(), fn (ReflectionParameter $reflectionParameter): bool => ! $reflectionParameter->isOptional()),
); );
if (array_diff($testParameterNames, $datasetParameterNames) === []) { if (array_diff($testParameterNames, $datasetParameterNames) === []) {
return; return;
} }
if (isset($testParameterNames[0])
&& $suppliedParametersCount >= $requiredParametersCount) { if (isset($testParameterNames[0]) && $suppliedParametersCount >= $requiredParametersCount) {
return; return;
} }
@ -426,15 +444,7 @@ trait Testable
return; return;
} }
if (count($this->__snapshotChanges) === 1) { $this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
$this->markTestIncomplete($this->__snapshotChanges[0]);
return;
}
$messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges));
$this->markTestIncomplete($messages);
} }
/** /**
@ -458,7 +468,7 @@ trait Testable
*/ */
public static function getLatestPrintableTestCaseMethodName(): string public static function getLatestPrintableTestCaseMethodName(): string
{ {
return self::$__latestDescription; return self::$__latestDescription ?? '';
} }
/** /**
@ -473,4 +483,12 @@ trait Testable
'notes' => self::$__latestNotes, 'notes' => self::$__latestNotes,
]; ];
} }
/**
* Opens a shell for the test case.
*/
public function shell(): void
{
Shell::open();
}
} }

View File

@ -79,11 +79,11 @@ final readonly class Configuration
} }
/** /**
* Gets the theme configuration. * Gets the printer configuration.
*/ */
public function theme(): Configuration\Theme public function printer(): Configuration\Printer
{ {
return new Configuration\Theme; return new Configuration\Printer;
} }
/** /**
@ -102,6 +102,14 @@ final readonly class Configuration
return Configuration\Project::getInstance(); return Configuration\Project::getInstance();
} }
/**
* Gets the browser configuration.
*/
public function browser(): Browser\Configuration
{
return new Browser\Configuration;
}
/** /**
* Proxies calls to the uses method. * Proxies calls to the uses method.
* *

View File

@ -9,7 +9,7 @@ use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
/** /**
* @internal * @internal
*/ */
final readonly class Theme final readonly class Printer
{ {
/** /**
* Sets the theme to compact. * Sets the theme to compact.

View File

@ -89,7 +89,7 @@ final class Project
{ {
$this->issues = "https://{$namespace}.atlassian.net/browse/{$project}-%s"; $this->issues = "https://{$namespace}.atlassian.net/browse/{$project}-%s";
$this->assignees = "https://{$namespace}.atlassian.net/secure/ViewProfile?name=%s"; $this->assignees = "https://{$namespace}.atlassian.net/secure/ViewProfile.jspa?name=%s";
return $this; return $this;
} }

View File

@ -16,7 +16,7 @@ final readonly class Help
* *
* @var array<int, string> * @var array<int, string>
*/ */
private const HELP_MESSAGES = [ private const array HELP_MESSAGES = [
'<comment>Pest Options:</comment>', '<comment>Pest Options:</comment>',
' <info>--init</info> Initialise a standard Pest configuration', ' <info>--init</info> Initialise a standard Pest configuration',
' <info>--coverage</info> Enable coverage and output to standard output', ' <info>--coverage</info> Enable coverage and output to standard output',

View File

@ -22,10 +22,14 @@ final readonly class Thanks
* *
* @var array<string, string> * @var array<string, string>
*/ */
private const FUNDING_MESSAGES = [ private const array FUNDING_MESSAGES = [
'Star' => 'https://github.com/pestphp/pest', 'Star' => 'https://github.com/pestphp/pest',
'News' => 'https://twitter.com/pestphp', 'YouTube' => 'https://youtube.com/@nunomaduro',
'Videos' => 'https://youtube.com/@nunomaduro', 'TikTok' => 'https://tiktok.com/@nunomaduro',
'Twitch' => 'https://twitch.tv/enunomaduro',
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
'Instagram' => 'https://instagram.com/enunomaduro',
'X' => 'https://x.com/enunomaduro',
'Sponsor' => 'https://github.com/sponsors/nunomaduro', 'Sponsor' => 'https://github.com/sponsors/nunomaduro',
]; ];

View File

@ -22,7 +22,7 @@ final class DatasetMissing extends BadFunctionCallException implements Exception
public function __construct(string $file, string $name, array $arguments) public function __construct(string $file, string $name, array $arguments)
{ {
parent::__construct(sprintf( parent::__construct(sprintf(
"A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s", 'A test with the description [%s] has [%d] argument(s) ([%s]) and no dataset(s) provided in [%s]',
$name, $name,
count($arguments), count($arguments),
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)), implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),

View File

@ -20,7 +20,7 @@ final class ShouldNotHappen extends RuntimeException
$message = $exception->getMessage(); $message = $exception->getMessage();
parent::__construct(sprintf(<<<'EOF' parent::__construct(sprintf(<<<'EOF'
This should not happen - please create an new issue here: https://github.com/pestphp/pest. This should not happen - please create an new issue here: https://github.com/pestphp/pest/issues
Issue: %s Issue: %s
PHP version: %s PHP version: %s

View File

@ -19,7 +19,11 @@ final class TestCaseAlreadyInUse extends InvalidArgumentException implements Exc
*/ */
public function __construct(string $inUse, string $newOne, string $folder) public function __construct(string $inUse, string $newOne, string $folder)
{ {
parent::__construct(sprintf('Test case `%s` can not be used. The folder `%s` already uses the test case `%s`', parent::__construct(sprintf(
$newOne, $folder, $inUse)); 'Test case [%s] can not be used. The folder [%s] already uses the test case [%s].',
$newOne,
$folder,
$inUse,
));
} }
} }

View File

@ -22,7 +22,7 @@ final class TestClosureMustNotBeStatic extends InvalidArgumentException implemen
{ {
parent::__construct( parent::__construct(
sprintf( sprintf(
'Test closure must not be static. Please remove the `static` keyword from the `%s` method in `%s`.', 'Test closure must not be static. Please remove the [static] keyword from the [%s] method in [%s].',
$method->description, $method->description,
$method->filename $method->filename
) )

View File

@ -223,7 +223,7 @@ final class Expectation
throw new BadMethodCallException('Expectation value is not iterable.'); throw new BadMethodCallException('Expectation value is not iterable.');
} }
if (count($callbacks) == 0) { if ($callbacks === []) {
throw new InvalidArgumentException('No sequence expectations defined.'); throw new InvalidArgumentException('No sequence expectations defined.');
} }
@ -264,7 +264,7 @@ final class Expectation
$matched = false; $matched = false;
foreach ($expressions as $key => $callback) { foreach ($expressions as $key => $callback) {
if ($subject != $key) { if ($subject != $key) { // @pest-arch-ignore-line
continue; continue;
} }
@ -330,7 +330,7 @@ final class Expectation
* @param array<int, mixed> $parameters * @param array<int, mixed> $parameters
* @return Expectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue> * @return Expectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue>
*/ */
public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation|ArchExpectation
{ {
if (! self::hasMethod($method)) { if (! self::hasMethod($method)) {
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) { if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) {
@ -355,6 +355,10 @@ final class Expectation
$reflectionClosure = new \ReflectionFunction($closure); $reflectionClosure = new \ReflectionFunction($closure);
$expectation = $reflectionClosure->getClosureThis(); $expectation = $reflectionClosure->getClosureThis();
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
return $closure(...$parameters);
}
assert(is_object($expectation)); assert(is_object($expectation));
ExpectationPipeline::for($closure) ExpectationPipeline::for($closure)
@ -380,7 +384,7 @@ final class Expectation
if (self::hasExtend($name)) { if (self::hasExtend($name)) {
$extend = self::$extends[$name]->bindTo($this, Expectation::class); $extend = self::$extends[$name]->bindTo($this, Expectation::class);
if ($extend != false) { if ($extend != false) { // @pest-arch-ignore-line
return $extend; return $extend;
} }
} }
@ -509,12 +513,25 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => (bool) preg_match('/^<\?php\s+declare\(.*?strict_types\s?=\s?1.*?\);/', (string) file_get_contents($object->path)), fn (ObjectDescription $object): bool => (bool) preg_match('/^<\?php\s*(\/\*[\s\S]*?\*\/|\/\/[^\r\n]*(?:\r?\n|$)|\s)*declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/m', (string) file_get_contents($object->path)),
'to use strict types', 'to use strict types',
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')), FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
); );
} }
/**
* Asserts that the given expectation target uses strict equality.
*/
public function toUseStrictEquality(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => ! str_contains((string) file_get_contents($object->path), ' == ') && ! str_contains((string) file_get_contents($object->path), ' != '),
'to use strict equality',
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' == ') || str_contains($line, ' != ')),
);
}
/** /**
* Asserts that the given expectation target is final. * Asserts that the given expectation target is final.
*/ */
@ -522,7 +539,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isFinal(), fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isFinal(),
'to be final', 'to be final',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -535,7 +552,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
'to be readonly', 'to be readonly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -548,7 +565,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isTrait(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isTrait(),
'to be trait', 'to be trait',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -569,7 +586,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isAbstract(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isAbstract(),
'to be abstract', 'to be abstract',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -586,7 +603,7 @@ final class Expectation
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => $object->reflectionClass->hasMethod($method))) === count($methods), fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod($method))) === count($methods),
sprintf("to have method '%s'", implode("', '", $methods)), sprintf("to have method '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -657,7 +674,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isEnum(),
'to be enum', 'to be enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -699,7 +716,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isInterface(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isInterface(),
'to be interface', 'to be interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -720,7 +737,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && ($class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class)),
sprintf("to extend '%s'", $class), sprintf("to extend '%s'", $class),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -760,6 +777,10 @@ final class Expectation
$this, $this,
function (ObjectDescription $object) use ($traits): bool { function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) { foreach ($traits as $trait) {
if (isset($object->reflectionClass) === false) {
return false;
}
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) { if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false; return false;
} }
@ -779,7 +800,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() === [], fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getInterfaceNames() === [],
'to implement nothing', 'to implement nothing',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -796,7 +817,8 @@ final class Expectation
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => count($interfaces) === count($object->reflectionClass->getInterfaceNames()) fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& (count($interfaces) === count($object->reflectionClass->getInterfaceNames()))
&& array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [], && array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [],
"to only implement '".implode("', '", $interfaces)."'", "to only implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
@ -810,7 +832,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => str_starts_with($object->reflectionClass->getShortName(), $prefix), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_starts_with($object->reflectionClass->getShortName(), $prefix),
"to have prefix '{$prefix}'", "to have prefix '{$prefix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -823,7 +845,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => str_ends_with($object->reflectionClass->getName(), $suffix), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_ends_with($object->reflectionClass->getName(), $suffix),
"to have suffix '{$suffix}'", "to have suffix '{$suffix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -842,7 +864,7 @@ final class Expectation
$this, $this,
function (ObjectDescription $object) use ($interfaces): bool { function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) { foreach ($interfaces as $interface) {
if (! $object->reflectionClass->implementsInterface($interface)) { if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false; return false;
} }
} }
@ -872,6 +894,14 @@ final class Expectation
return ToUseNothing::make($this); return ToUseNothing::make($this);
} }
/**
* Asserts that the source code of the given expectation target does not include suspicious characters.
*/
public function toHaveSuspiciousCharacters(): ArchExpectation
{
throw InvalidExpectation::fromMethods(['toHaveSuspiciousCharacters']);
}
/** /**
* Not supported. * Not supported.
*/ */
@ -915,7 +945,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->hasMethod('__invoke'), fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod('__invoke'),
'to be invokable', 'to be invokable',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')) FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
); );
@ -1024,7 +1054,7 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) !== [], fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [],
"to have attribute '{$attribute}'", "to have attribute '{$attribute}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -1053,7 +1083,8 @@ final class Expectation
{ {
return Targeted::make( return Targeted::make(
$this, $this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum() fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $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',

View File

@ -24,6 +24,7 @@ use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\ExpectationFailedException;
use ReflectionMethod; use ReflectionMethod;
use ReflectionProperty; use ReflectionProperty;
use Spoofchecker;
use stdClass; use stdClass;
/** /**
@ -74,7 +75,10 @@ final readonly class OppositeExpectation
*/ */
public function toUse(array|string $targets): ArchExpectation public function toUse(array|string $targets): ArchExpectation
{ {
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($this->original, $target)->opposite( /** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($original, $target)->opposite(
fn () => $this->throwExpectationFailedException('toUse', $target), fn () => $this->throwExpectationFailedException('toUse', $target),
), is_string($targets) ? [$targets] : $targets)); ), is_string($targets) ? [$targets] : $targets));
} }
@ -84,8 +88,11 @@ final readonly class OppositeExpectation
*/ */
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) !== $permissions, fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) !== $permissions,
sprintf('permissions not to be [%s]', $permissions), sprintf('permissions not to be [%s]', $permissions),
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')), FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
@ -105,8 +112,11 @@ final readonly class OppositeExpectation
*/ */
public function toHaveMethodsDocumented(): ArchExpectation public function toHaveMethodsDocumented(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| array_filter( || array_filter(
Reflection::getMethodsFromReflectionClass($object->reflectionClass), Reflection::getMethodsFromReflectionClass($object->reflectionClass),
@ -124,8 +134,11 @@ final readonly class OppositeExpectation
*/ */
public function toHavePropertiesDocumented(): ArchExpectation public function toHavePropertiesDocumented(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| array_filter( || array_filter(
Reflection::getPropertiesFromReflectionClass($object->reflectionClass), Reflection::getPropertiesFromReflectionClass($object->reflectionClass),
@ -144,22 +157,44 @@ final readonly class OppositeExpectation
*/ */
public function toUseStrictTypes(): ArchExpectation public function toUseStrictTypes(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! (bool) preg_match('/^<\?php\s+declare\(.*?strict_types\s?=\s?1.*?\);/', (string) file_get_contents($object->path)), fn (ObjectDescription $object): bool => ! (bool) preg_match('/^<\?php\s+declare\(.*?strict_types\s?=\s?1.*?\);/', (string) file_get_contents($object->path)),
'not to use strict types', 'not to use strict types',
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')), FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
); );
} }
/**
* Asserts that the given expectation target does not use the strict equality operator.
*/
public function toUseStrictEquality(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => ! str_contains((string) file_get_contents($object->path), ' === ') && ! str_contains((string) file_get_contents($object->path), ' !== '),
'to use strict equality',
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' === ') || str_contains($line, ' !== ')),
);
}
/** /**
* Asserts that the given expectation target is not final. * Asserts that the given expectation target is not final.
*/ */
public function toBeFinal(): ArchExpectation public function toBeFinal(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isFinal(), fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isFinal()),
'not to be final', 'not to be final',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -170,9 +205,12 @@ final readonly class OppositeExpectation
*/ */
public function toBeReadonly(): ArchExpectation public function toBeReadonly(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isReadOnly()) && assert(true), // @phpstan-ignore-line
'not to be readonly', 'not to be readonly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -183,9 +221,12 @@ final readonly class OppositeExpectation
*/ */
public function toBeTrait(): ArchExpectation public function toBeTrait(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isTrait(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isTrait(),
'not to be trait', 'not to be trait',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -204,9 +245,12 @@ final readonly class OppositeExpectation
*/ */
public function toBeAbstract(): ArchExpectation public function toBeAbstract(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isAbstract(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isAbstract(),
'not to be abstract', 'not to be abstract',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -221,17 +265,38 @@ final readonly class OppositeExpectation
{ {
$methods = is_array($method) ? $method : [$method]; $methods = is_array($method) ? $method : [$method];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => array_filter( fn (ObjectDescription $object): bool => array_filter(
$methods, $methods,
fn (string $method): bool => $object->reflectionClass->hasMethod($method), fn (string $method): bool => isset($object->reflectionClass) === false || $object->reflectionClass->hasMethod($method),
) === [], ) === [],
'to not have methods: '.implode(', ', $methods), 'to not have methods: '.implode(', ', $methods),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
} }
/**
* Asserts that the given expectation target does not have suspicious characters.
*/
public function toHaveSuspiciousCharacters(): ArchExpectation
{
$checker = new Spoofchecker;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => ! $checker->isSuspicious((string) file_get_contents($object->path)),
'to not include suspicious characters',
FileLineFinder::where(fn (string $line): bool => $checker->isSuspicious($line)),
);
}
/** /**
* Asserts that the given expectation target does not have the given methods. * Asserts that the given expectation target does not have the given methods.
* *
@ -253,8 +318,11 @@ final readonly class OppositeExpectation
$state = new stdClass; $state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
function (ObjectDescription $object) use ($methods, &$state): bool { function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass) $reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC) ? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC)
@ -273,7 +341,7 @@ final readonly class OppositeExpectation
$methods === [] $methods === []
? 'not to have public methods' ? 'not to have public methods'
: sprintf("not to have public methods besides '%s'", implode("', '", $methods)), : sprintf("not to have public methods besides '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, $state->contains)), FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
); );
} }
@ -296,8 +364,11 @@ final readonly class OppositeExpectation
$state = new stdClass; $state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
function (ObjectDescription $object) use ($methods, &$state): bool { function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass) $reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED) ? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED)
@ -316,7 +387,7 @@ final readonly class OppositeExpectation
$methods === [] $methods === []
? 'not to have protected methods' ? 'not to have protected methods'
: sprintf("not to have protected methods besides '%s'", implode("', '", $methods)), : sprintf("not to have protected methods besides '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, $state->contains)), FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
); );
} }
@ -339,8 +410,11 @@ final readonly class OppositeExpectation
$state = new stdClass; $state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
function (ObjectDescription $object) use ($methods, &$state): bool { function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass) $reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE) ? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE)
@ -359,7 +433,7 @@ final readonly class OppositeExpectation
$methods === [] $methods === []
? 'not to have private methods' ? 'not to have private methods'
: sprintf("not to have private methods besides '%s'", implode("', '", $methods)), : sprintf("not to have private methods besides '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, $state->contains)), FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
); );
} }
@ -376,9 +450,12 @@ final readonly class OppositeExpectation
*/ */
public function toBeEnum(): ArchExpectation public function toBeEnum(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isEnum(),
'not to be enum', 'not to be enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -397,8 +474,11 @@ final readonly class OppositeExpectation
*/ */
public function toBeClass(): ArchExpectation public function toBeClass(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! class_exists($object->name), fn (ObjectDescription $object): bool => ! class_exists($object->name),
'not to be class', 'not to be class',
FileLineFinder::where(fn (string $line): bool => true), FileLineFinder::where(fn (string $line): bool => true),
@ -418,9 +498,12 @@ final readonly class OppositeExpectation
*/ */
public function toBeInterface(): ArchExpectation public function toBeInterface(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isInterface(), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isInterface(),
'not to be interface', 'not to be interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -439,9 +522,12 @@ final readonly class OppositeExpectation
*/ */
public function toExtend(string $class): ArchExpectation public function toExtend(string $class): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isSubclassOf($class), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isSubclassOf($class),
sprintf("not to extend '%s'", $class), sprintf("not to extend '%s'", $class),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -452,9 +538,12 @@ final readonly class OppositeExpectation
*/ */
public function toExtendNothing(): ArchExpectation public function toExtendNothing(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() !== false, fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getParentClass() !== false,
'to extend a class', 'to extend a class',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -477,11 +566,14 @@ final readonly class OppositeExpectation
{ {
$traits = is_array($traits) ? $traits : [$traits]; $traits = is_array($traits) ? $traits : [$traits];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
function (ObjectDescription $object) use ($traits): bool { function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) { foreach ($traits as $trait) {
if (in_array($trait, $object->reflectionClass->getTraitNames(), true)) { if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false; return false;
} }
} }
@ -502,11 +594,14 @@ final readonly class OppositeExpectation
{ {
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces]; $interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
function (ObjectDescription $object) use ($interfaces): bool { function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) { foreach ($interfaces as $interface) {
if ($object->reflectionClass->implementsInterface($interface)) { if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false; return false;
} }
} }
@ -523,9 +618,12 @@ final readonly class OppositeExpectation
*/ */
public function toImplementNothing(): ArchExpectation public function toImplementNothing(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() !== [], fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getInterfaceNames() !== [],
'to implement an interface', 'to implement an interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -544,9 +642,12 @@ final readonly class OppositeExpectation
*/ */
public function toHavePrefix(string $prefix): ArchExpectation public function toHavePrefix(string $prefix): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! str_starts_with($object->reflectionClass->getShortName(), $prefix), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
"not to have prefix '{$prefix}'", "not to have prefix '{$prefix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -557,9 +658,12 @@ final readonly class OppositeExpectation
*/ */
public function toHaveSuffix(string $suffix): ArchExpectation public function toHaveSuffix(string $suffix): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! str_ends_with($object->reflectionClass->getName(), $suffix), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_ends_with($object->reflectionClass->getName(), $suffix),
"not to have suffix '{$suffix}'", "not to have suffix '{$suffix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
); );
@ -586,7 +690,10 @@ final readonly class OppositeExpectation
*/ */
public function toBeUsed(): ArchExpectation public function toBeUsed(): ArchExpectation
{ {
return ToBeUsedInNothing::make($this->original); /** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return ToBeUsedInNothing::make($original);
} }
/** /**
@ -596,7 +703,10 @@ final readonly class OppositeExpectation
*/ */
public function toBeUsedIn(array|string $targets): ArchExpectation public function toBeUsedIn(array|string $targets): ArchExpectation
{ {
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($this->original, $target)->opposite( /** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($original, $target)->opposite(
fn () => $this->throwExpectationFailedException('toBeUsedIn', $target), fn () => $this->throwExpectationFailedException('toBeUsedIn', $target),
), is_string($targets) ? [$targets] : $targets)); ), is_string($targets) ? [$targets] : $targets));
} }
@ -619,9 +729,12 @@ final readonly class OppositeExpectation
*/ */
public function toBeInvokable(): ArchExpectation public function toBeInvokable(): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->hasMethod('__invoke'), fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->hasMethod('__invoke'),
'to not be invokable', 'to not be invokable',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')) FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
); );
@ -632,9 +745,12 @@ final readonly class OppositeExpectation
*/ */
public function toHaveAttribute(string $attribute): ArchExpectation public function toHaveAttribute(string $attribute): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) === [], fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getAttributes($attribute) === [],
"to not have attribute '{$attribute}'", "to not have attribute '{$attribute}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')) FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
); );
@ -724,9 +840,13 @@ final readonly class OppositeExpectation
*/ */
private function toBeBackedEnum(string $backingType): ArchExpectation private function toBeBackedEnum(string $backingType): ArchExpectation
{ {
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make( return Targeted::make(
$this->original, $original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum() fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $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',

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversNothing {}

View File

@ -166,7 +166,7 @@ final class TestCaseFactory
} }
PHP; PHP;
eval($classCode); // @phpstan-ignore-line eval($classCode);
} catch (ParseError $caught) { } catch (ParseError $caught) {
throw new RuntimeException(sprintf( throw new RuntimeException(sprintf(
"Unable to create test case for test file at %s. \n %s", "Unable to create test case for test file at %s. \n %s",

View File

@ -31,8 +31,10 @@ final class TestCaseMethodFactory
/** /**
* The test's describing, if any. * The test's describing, if any.
*
* @var array<int, \Pest\Support\Description>
*/ */
public ?string $describing = null; public array $describing = [];
/** /**
* The test's description, if any. * The test's description, if any.
@ -118,9 +120,9 @@ final class TestCaseMethodFactory
} }
/** /**
* Creates the test's closure. * Sets the test's hooks, and runs any proxy to the test case.
*/ */
public function getClosure(TestCase $concrete): Closure public function setUp(TestCase $concrete): void
{ {
$concrete::flush(); // @phpstan-ignore-line $concrete::flush(); // @phpstan-ignore-line
@ -128,17 +130,32 @@ final class TestCaseMethodFactory
throw ShouldNotHappen::fromMessage('Description can not be empty.'); throw ShouldNotHappen::fromMessage('Description can not be empty.');
} }
$closure = $this->closure;
$testCase = TestSuite::getInstance()->tests->get($this->filename); $testCase = TestSuite::getInstance()->tests->get($this->filename);
assert($testCase instanceof TestCaseFactory); assert($testCase instanceof TestCaseFactory);
$testCase->factoryProxies->proxy($concrete); $testCase->factoryProxies->proxy($concrete);
$this->factoryProxies->proxy($concrete); $this->factoryProxies->proxy($concrete);
}
/**
* Flushes the test case.
*/
public function tearDown(TestCase $concrete): void
{
$concrete::flush(); // @phpstan-ignore-line
}
/**
* Creates the test's closure.
*/
public function getClosure(): Closure
{
$closure = $this->closure;
$testCase = TestSuite::getInstance()->tests->get($this->filename);
assert($testCase instanceof TestCaseFactory);
$method = $this; $method = $this;
return function (...$arguments) use ($testCase, $method, $closure): mixed { // @phpstan-ignore-line return function (...$arguments) use ($testCase, $method, $closure): mixed {
/* @var TestCase $this */ /* @var TestCase $this */
$testCase->proxies->proxy($this); $testCase->proxies->proxy($this);
$method->proxies->proxy($this); $method->proxies->proxy($this);
@ -186,7 +203,7 @@ final class TestCaseMethodFactory
]; ];
foreach ($this->depends as $depend) { foreach ($this->depends as $depend) {
$depend = Str::evaluable($this->describing !== null ? Str::describe($this->describing, $depend) : $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, \PHPUnit\Framework\Attributes\Depends::class,
@ -209,10 +226,8 @@ final class TestCaseMethodFactory
$attributesCode $attributesCode
public function $methodName(...\$arguments) public function $methodName(...\$arguments)
{ {
\$test = \Pest\TestSuite::getInstance()->tests->get(self::\$__filename)->getMethod(\$this->name())->getClosure(\$this);
return \$this->__runTest( return \$this->__runTest(
\$test, \$this->__test,
...\$arguments, ...\$arguments,
); );
} }

View File

@ -2,11 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Browser\Api\ArrayablePendingAwaitablePage;
use Pest\Browser\Api\PendingAwaitablePage;
use Pest\Concerns\Expectable; 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;
use Pest\Expectation; use Pest\Expectation;
use Pest\Installers\PluginBrowser;
use Pest\Mutate\Contracts\MutationTestRunner; use Pest\Mutate\Contracts\MutationTestRunner;
use Pest\Mutate\Repositories\ConfigurationRepository; use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\PendingCalls\AfterEachCall; use Pest\PendingCalls\AfterEachCall;
@ -18,6 +21,7 @@ use Pest\Repositories\DatasetsRepository;
use Pest\Support\Backtrace; use Pest\Support\Backtrace;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\DatasetInfo; use Pest\Support\DatasetInfo;
use Pest\Support\Description;
use Pest\Support\HigherOrderTapProxy; use Pest\Support\HigherOrderTapProxy;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -43,7 +47,7 @@ if (! function_exists('beforeAll')) {
*/ */
function beforeAll(Closure $closure): void function beforeAll(Closure $closure): void
{ {
if (! is_null(DescribeCall::describing())) { if (DescribeCall::describing() !== []) {
$filename = Backtrace::file(); $filename = Backtrace::file();
throw new BeforeAllWithinDescribe($filename); throw new BeforeAllWithinDescribe($filename);
@ -95,7 +99,7 @@ if (! function_exists('describe')) {
{ {
$filename = Backtrace::testFile(); $filename = Backtrace::testFile();
return new DescribeCall(TestSuite::getInstance(), $filename, $description, $tests); return new DescribeCall(TestSuite::getInstance(), $filename, new Description($description), $tests);
} }
} }
@ -205,7 +209,7 @@ if (! function_exists('afterAll')) {
*/ */
function afterAll(Closure $closure): void function afterAll(Closure $closure): void
{ {
if (! is_null(DescribeCall::describing())) { if (DescribeCall::describing() !== []) {
$filename = Backtrace::file(); $filename = Backtrace::file();
throw new AfterAllWithinDescribe($filename); throw new AfterAllWithinDescribe($filename);
@ -217,7 +221,7 @@ if (! function_exists('afterAll')) {
if (! function_exists('covers')) { if (! function_exists('covers')) {
/** /**
* Specifies which classes, or functions, a test method covers. * Specifies which classes, or functions, a test case covers.
* *
* @param array<int, string>|string $classesOrFunctions * @param array<int, string>|string $classesOrFunctions
*/ */
@ -243,3 +247,86 @@ if (! function_exists('covers')) {
} }
} }
} }
if (! function_exists('mutates')) {
/**
* Specifies which classes, enums, or traits a test case mutates.
*
* @param array<int, string>|string $targets
*/
function mutates(array|string ...$targets): void
{
$filename = Backtrace::file();
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
$beforeEachCall->group('__pest_mutate_only');
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if ($runner->isEnabled() && ! $everything && ! is_array($classes) && ! is_array($paths)) {
$beforeEachCall->only('__pest_mutate_only');
}
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if (! is_array($paths)) {
$configurationRepository->globalConfiguration('default')->class(...$targets); // @phpstan-ignore-line
}
}
}
if (! function_exists('fixture')) {
/**
* Returns the absolute path to a fixture file.
*/
function fixture(string $file): string
{
$file = implode(DIRECTORY_SEPARATOR, [
TestSuite::getInstance()->rootPath,
TestSuite::getInstance()->testPath,
'Fixtures',
str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file),
]);
$fileRealPath = realpath($file);
if ($fileRealPath === false) {
throw new InvalidArgumentException(
'The fixture file ['.$file.'] does not exist.',
);
}
return $fileRealPath;
}
}
if (! function_exists('visit')) {
/**
* Browse to the given URL.
*
* @template TUrl of array<int, string>|string
*
* @param TUrl $url
* @param array<string, mixed> $options
* @return (TUrl is array<int, string> ? ArrayablePendingAwaitablePage : PendingAwaitablePage)
*/
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
{
if (! class_exists(\Pest\Browser\Configuration::class)) {
PluginBrowser::install();
exit(0);
}
// @phpstan-ignore-next-line
return test()->visit($url, $options);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Installers;
use Pest\Support\View;
final readonly class PluginBrowser
{
public static function install(): void
{
View::render('installers/plugin-browser');
}
}

View File

@ -34,7 +34,7 @@ final readonly class Kernel
* *
* @var array<int, class-string> * @var array<int, class-string>
*/ */
private const BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,

View File

@ -40,7 +40,7 @@ final class KernelDump
*/ */
public function disable(): void public function disable(): void
{ {
@ob_clean(); // @phpstan-ignore-line @ob_clean();
if ($this->buffer !== '') { if ($this->buffer !== '') {
$this->flush(); $this->flush();

View File

@ -11,6 +11,7 @@ use Pest\Support\Str;
use PHPUnit\Event\Code\Test; 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\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\ConsideredRisky; use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
@ -18,6 +19,7 @@ use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete; use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite; use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
use PHPUnit\Framework\Exception as FrameworkException; use PHPUnit\Framework\Exception as FrameworkException;
use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult; use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult;
@ -29,7 +31,7 @@ final readonly class Converter
/** /**
* The prefix for the test suite name. * The prefix for the test suite name.
*/ */
private const PREFIX = 'P\\'; private const string PREFIX = 'P\\';
/** /**
* The state generator. * The state generator.
@ -147,6 +149,13 @@ final readonly class Converter
*/ */
public function getTestSuiteName(TestSuite $testSuite): string public function getTestSuiteName(TestSuite $testSuite): string
{ {
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$firstTest = $this->getFirstTest($testSuite);
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
}
}
$name = $testSuite->name(); $name = $testSuite->name();
if (! str_starts_with($name, self::PREFIX)) { if (! str_starts_with($name, self::PREFIX)) {
@ -168,6 +177,35 @@ final readonly class Converter
* Gets the test suite location. * Gets the test suite location.
*/ */
public function getTestSuiteLocation(TestSuite $testSuite): ?string public function getTestSuiteLocation(TestSuite $testSuite): ?string
{
$firstTest = $this->getFirstTest($testSuite);
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
return null;
}
$path = $firstTest->testDox()->prettifiedClassName();
$classRelativePath = $this->toRelativePath($path);
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$methodName = $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
return "$classRelativePath::$methodName";
}
return $classRelativePath;
}
/**
* Gets the prettified test method name without dataset-related suffix.
*/
private function getTestMethodNameWithoutDatasetSuffix(TestMethod $testMethod): string
{
return Str::beforeLast($testMethod->testDox()->prettifiedMethodName(), ' with data set ');
}
/**
* Gets the first test from the test suite.
*/
private function getFirstTest(TestSuite $testSuite): ?TestMethod
{ {
$tests = $testSuite->tests()->asArray(); $tests = $testSuite->tests()->asArray();
@ -181,9 +219,7 @@ final readonly class Converter
throw ShouldNotHappen::fromMessage('Not an instance of TestMethod'); throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
} }
$path = $firstTest->testDox()->prettifiedClassName(); return $firstTest;
return $this->toRelativePath($path);
} }
/** /**
@ -219,8 +255,9 @@ final readonly class Converter
$numberOfNotPassedTests = count( $numberOfNotPassedTests = count(
array_unique( array_unique(
array_map( array_map(
function (BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string { function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored) { if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) {
return $event->testClassName(); return $event->testClassName();
} }

View File

@ -38,7 +38,7 @@ final class ServiceMessage
{ {
return new self('testSuiteStarted', [ return new self('testSuiteStarted', [
'name' => $name, 'name' => $name,
'locationHint' => $location === null ? null : "file://$location", 'locationHint' => $location === null ? null : "pest_qn://$location",
]); ]);
} }

View File

@ -232,7 +232,6 @@ final class TeamCityLogger
$reflector = new ReflectionClass($telemetry); $reflector = new ReflectionClass($telemetry);
$property = $reflector->getProperty('current'); $property = $reflector->getProperty('current');
$property->setAccessible(true);
$snapshot = $property->getValue($telemetry); $snapshot = $property->getValue($telemetry);
assert($snapshot instanceof Snapshot); assert($snapshot instanceof Snapshot);

View File

@ -183,7 +183,6 @@ final class Expectation
{ {
foreach ($needles as $needle) { foreach ($needles as $needle) {
if (is_string($this->value)) { if (is_string($this->value)) {
// @phpstan-ignore-next-line
Assert::assertStringContainsString((string) $needle, $this->value); Assert::assertStringContainsString((string) $needle, $this->value);
} else { } else {
if (! is_iterable($this->value)) { if (! is_iterable($this->value)) {
@ -782,15 +781,13 @@ final class Expectation
foreach ($array as $key => $value) { foreach ($array as $key => $value) {
Assert::assertArrayHasKey($key, $valueAsArray, $message); Assert::assertArrayHasKey($key, $valueAsArray, $message);
if ($message === '') { $assertMessage = $message !== '' ? $message : sprintf(
$message = sprintf( 'Failed asserting that an array has a key %s with the value %s.',
'Failed asserting that an array has a key %s with the value %s.', $this->export($key),
$this->export($key), $this->export($valueAsArray[$key]),
$this->export($valueAsArray[$key]), );
);
}
Assert::assertEquals($value, $valueAsArray[$key], $message); Assert::assertEquals($value, $valueAsArray[$key], $assertMessage);
} }
return $this; return $this;
@ -803,7 +800,7 @@ final class Expectation
* @param iterable<string, mixed> $object * @param iterable<string, mixed> $object
* @return self<TValue> * @return self<TValue>
*/ */
public function toMatchObject(iterable $object, string $message = ''): self public function toMatchObject(object|iterable $object, string $message = ''): self
{ {
foreach ((array) $object as $property => $value) { foreach ((array) $object as $property => $value) {
if (! is_object($this->value) && ! is_string($this->value)) { if (! is_object($this->value) && ! is_string($this->value)) {
@ -815,15 +812,13 @@ final class Expectation
/* @phpstan-ignore-next-line */ /* @phpstan-ignore-next-line */
$propertyValue = $this->value->{$property}; $propertyValue = $this->value->{$property};
if ($message === '') { $assertMessage = $message !== '' ? $message : sprintf(
$message = sprintf( 'Failed asserting that an object has a property %s with the value %s.',
'Failed asserting that an object has a property %s with the value %s.', $this->export($property),
$this->export($property), $this->export($propertyValue),
$this->export($propertyValue), );
);
}
Assert::assertEquals($value, $propertyValue, $message); Assert::assertEquals($value, $propertyValue, $assertMessage);
} }
return $this; return $this;
@ -1159,4 +1154,21 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value can be converted to a slug
*
* @return self<TValue>
*/
public function toBeSlug(string $message = ''): self
{
if ($message === '') {
$message = "Failed asserting that {$this->value} can be converted to a slug.";
}
$slug = Str::slugify((string) $this->value);
Assert::assertNotEmpty($slug, $message);
return $this;
}
} }

View File

@ -46,7 +46,7 @@ final readonly class Panic
{ {
try { try {
$output = Container::getInstance()->get(OutputInterface::class); $output = Container::getInstance()->get(OutputInterface::class);
} catch (Throwable) { // @phpstan-ignore-line } catch (Throwable) {
$output = new ConsoleOutput; $output = new ConsoleOutput;
} }

View File

@ -6,6 +6,7 @@ namespace Pest\PendingCalls;
use Closure; use Closure;
use Pest\PendingCalls\Concerns\Describable; use Pest\PendingCalls\Concerns\Describable;
use Pest\Support\Arr;
use Pest\Support\Backtrace; use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection; use Pest\Support\HigherOrderMessageCollection;
@ -54,8 +55,8 @@ final class AfterEachCall
$proxies = $this->proxies; $proxies = $this->proxies;
$afterEachTestCase = ChainableClosure::boundWhen( $afterEachTestCase = ChainableClosure::boundWhen(
fn (): bool => is_null($describing) || $this->__describing === $describing, fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class),
)->bindTo($this, self::class); )->bindTo($this, self::class);
assert($afterEachTestCase instanceof Closure); assert($afterEachTestCase instanceof Closure);

View File

@ -7,6 +7,7 @@ namespace Pest\PendingCalls;
use Closure; use Closure;
use Pest\Exceptions\AfterBeforeTestFunction; use Pest\Exceptions\AfterBeforeTestFunction;
use Pest\PendingCalls\Concerns\Describable; use Pest\PendingCalls\Concerns\Describable;
use Pest\Support\Arr;
use Pest\Support\Backtrace; use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection; use Pest\Support\HigherOrderMessageCollection;
@ -63,12 +64,12 @@ final class BeforeEachCall
$beforeEachTestCall = function (TestCall $testCall) use ($describing): void { $beforeEachTestCall = function (TestCall $testCall) use ($describing): void {
if ($this->describing !== null) { if ($this->describing !== []) {
if ($describing !== $this->describing) { if (Arr::last($describing) !== Arr::last($this->describing)) {
return; return;
} }
if ($describing !== $testCall->describing) { if (! in_array(Arr::last($describing), $testCall->describing, true)) {
return; return;
} }
} }
@ -77,8 +78,8 @@ final class BeforeEachCall
}; };
$beforeEachTestCase = ChainableClosure::boundWhen( $beforeEachTestCase = ChainableClosure::boundWhen(
fn (): bool => is_null($describing) || $this->__describing === $describing, fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class),
)->bindTo($this, self::class); )->bindTo($this, self::class);
assert($beforeEachTestCase instanceof Closure); assert($beforeEachTestCase instanceof Closure);
@ -96,7 +97,7 @@ final class BeforeEachCall
*/ */
public function after(Closure $closure): self public function after(Closure $closure): self
{ {
if ($this->describing === null) { if ($this->describing === []) {
throw new AfterBeforeTestFunction($this->filename); throw new AfterBeforeTestFunction($this->filename);
} }

View File

@ -11,11 +11,15 @@ 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>
*/ */
public string $__describing; public array $__describing;
/** /**
* The describing of the test case. * The describing of the test case.
*
* @var array<int, \Pest\Support\Description>
*/ */
public ?string $describing = null; public array $describing = [];
} }

View File

@ -6,6 +6,7 @@ namespace Pest\PendingCalls;
use Closure; use Closure;
use Pest\Support\Backtrace; use Pest\Support\Backtrace;
use Pest\Support\Description;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
@ -15,8 +16,10 @@ final class DescribeCall
{ {
/** /**
* The current describe call. * The current describe call.
*
* @var array<int, Description>
*/ */
private static ?string $describing = null; private static array $describing = [];
/** /**
* The describe "before each" call. * The describe "before each" call.
@ -29,7 +32,7 @@ final class DescribeCall
public function __construct( public function __construct(
public readonly TestSuite $testSuite, public readonly TestSuite $testSuite,
public readonly string $filename, public readonly string $filename,
public readonly string $description, public readonly Description $description,
public readonly Closure $tests public readonly Closure $tests
) { ) {
// //
@ -37,8 +40,10 @@ final class DescribeCall
/** /**
* What is the current describing. * What is the current describing.
*
* @return array<int, Description>
*/ */
public static function describing(): ?string public static function describing(): array
{ {
return self::$describing; return self::$describing;
} }
@ -50,12 +55,12 @@ final class DescribeCall
{ {
unset($this->currentBeforeEachCall); unset($this->currentBeforeEachCall);
self::$describing = $this->description; self::$describing[] = $this->description;
try { try {
($this->tests)(); ($this->tests)();
} finally { } finally {
self::$describing = null; array_pop(self::$describing);
} }
} }
@ -71,10 +76,10 @@ final class DescribeCall
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) { if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) {
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename); $this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
$this->currentBeforeEachCall->describing = $this->description; $this->currentBeforeEachCall->describing[] = $this->description;
} }
$this->currentBeforeEachCall->{$name}(...$arguments); // @phpstan-ignore-line $this->currentBeforeEachCall->{$name}(...$arguments);
return $this; return $this;
} }

View File

@ -5,12 +5,14 @@ declare(strict_types=1);
namespace Pest\PendingCalls; namespace Pest\PendingCalls;
use Closure; use Closure;
use Pest\Concerns\Testable;
use Pest\Exceptions\InvalidArgumentException; use Pest\Exceptions\InvalidArgumentException;
use Pest\Exceptions\TestDescriptionMissing; use Pest\Exceptions\TestDescriptionMissing;
use Pest\Factories\Attribute; use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory; use Pest\Factories\TestCaseMethodFactory;
use Pest\Mutate\Repositories\ConfigurationRepository; use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\PendingCalls\Concerns\Describable; use Pest\PendingCalls\Concerns\Describable;
use Pest\Plugins\Environment;
use Pest\Plugins\Only; use Pest\Plugins\Only;
use Pest\Support\Backtrace; use Pest\Support\Backtrace;
use Pest\Support\Container; use Pest\Support\Container;
@ -25,9 +27,9 @@ use PHPUnit\Framework\TestCase;
/** /**
* @internal * @internal
* *
* @mixin HigherOrderCallables|TestCase * @mixin HigherOrderCallables|TestCase|Testable
*/ */
final class TestCall final class TestCall // @phpstan-ignore-line
{ {
use Describable; use Describable;
@ -75,7 +77,7 @@ final class TestCall
throw new TestDescriptionMissing($this->filename); throw new TestDescriptionMissing($this->filename);
} }
$description = is_null($this->describing) $description = $this->describing === []
? $this->description ? $this->description
: Str::describe($this->describing, $this->description); : Str::describe($this->describing, $this->description);
@ -177,10 +179,9 @@ final class TestCall
} }
/** /**
* Runs the current test multiple times with * Runs the current test multiple times with each item of the given `iterable`.
* each item of the given `iterable`.
* *
* @param array<\Closure|iterable<int|string, mixed>|string> $data * @param Closure|iterable<array-key, mixed>|string $data
*/ */
public function with(Closure|iterable|string ...$data): self public function with(Closure|iterable|string ...$data): self
{ {
@ -223,7 +224,7 @@ final class TestCall
*/ */
public function only(): self public function only(): self
{ {
Only::enable($this, ...func_get_args()); // @phpstan-ignore-line Only::enable($this, ...func_get_args());
return $this; return $this;
} }
@ -314,6 +315,61 @@ final class TestCall
: $this; : $this;
} }
/**
* Weather the current test is running on a CI environment.
*/
private function runningOnCI(): bool
{
foreach ([
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'APPVEYOR',
'BITBUCKET_BUILD_NUMBER',
'BUILDKITE',
'TEAMCITY_VERSION',
'JENKINS_URL',
'SYSTEM_COLLECTIONURI',
'CI_NAME',
'TASKCLUSTER_ROOT_URL',
'DRONE',
'WERCKER',
'NEVERCODE',
'SEMAPHORE',
'NETLIFY',
'NOW_BUILDER',
] as $env) {
if (getenv($env) !== false) {
return true;
}
}
return Environment::name() === Environment::CI;
}
/**
* Skips the current test when running on a CI environments.
*/
public function skipOnCI(): self
{
if ($this->runningOnCI()) {
return $this->skip('This test is skipped on [CI].');
}
return $this;
}
public function skipLocally(): self
{
if ($this->runningOnCI() === false) {
return $this->skip('This test is skipped [locally].');
}
return $this;
}
/** /**
* Skips the current test unless the given test is running on Windows. * Skips the current test unless the given test is running on Windows.
*/ */
@ -358,8 +414,8 @@ final class TestCall
public function todo(// @phpstan-ignore-line public function todo(// @phpstan-ignore-line
array|string|null $note = null, array|string|null $note = null,
array|string|null $assignee = null, array|string|null $assignee = null,
array|string|null $issue = null, array|string|int|null $issue = null,
array|string|null $pr = null, array|string|int|null $pr = null,
): self { ): self {
$this->skip('__TODO__'); $this->skip('__TODO__');
@ -390,8 +446,8 @@ final class TestCall
public function wip(// @phpstan-ignore-line public function wip(// @phpstan-ignore-line
array|string|null $note = null, array|string|null $note = null,
array|string|null $assignee = null, array|string|null $assignee = null,
array|string|null $issue = null, array|string|int|null $issue = null,
array|string|null $pr = null, array|string|int|null $pr = null,
): self { ): self {
if ($issue !== null) { if ($issue !== null) {
$this->issue($issue); $this->issue($issue);
@ -418,8 +474,8 @@ final class TestCall
public function done(// @phpstan-ignore-line public function done(// @phpstan-ignore-line
array|string|null $note = null, array|string|null $note = null,
array|string|null $assignee = null, array|string|null $assignee = null,
array|string|null $issue = null, array|string|int|null $issue = null,
array|string|null $pr = null, array|string|int|null $pr = null,
): self { ): self {
if ($issue !== null) { if ($issue !== null) {
$this->issue($issue); $this->issue($issue);
@ -603,18 +659,29 @@ final class TestCall
} }
/** /**
* Sets that the current test covers nothing. * Adds one or more references to the tested method or class. This helps
* to link test cases to the source code for easier navigation.
*
* @param array<class-string|string>|class-string ...$classes
*/ */
public function coversNothing(): self public function references(string|array ...$classes): self
{ {
$this->testCaseMethod->attributes[] = new Attribute( assert($classes !== []);
\PHPUnit\Framework\Attributes\CoversNothing::class,
[],
);
return $this; return $this;
} }
/**
* Adds one or more references to the tested method or class. This helps
* to link test cases to the source code for easier navigation.
*
* @param array<class-string|string>|class-string ...$classes
*/
public function see(string|array ...$classes): self
{
return $this->references(...$classes);
}
/** /**
* Informs the test runner that no expectations happen in this test, * Informs the test runner that no expectations happen in this test,
* and its purpose is simply to check whether the given code can * and its purpose is simply to check whether the given code can
@ -682,7 +749,7 @@ final class TestCall
throw new TestDescriptionMissing($this->filename); throw new TestDescriptionMissing($this->filename);
} }
if (! is_null($this->describing)) { if ($this->describing !== []) {
$this->testCaseMethod->describing = $this->describing; $this->testCaseMethod->describing = $this->describing;
$this->testCaseMethod->description = Str::describe($this->describing, $this->description); $this->testCaseMethod->description = Str::describe($this->describing, $this->description);
} else { } else {
@ -692,7 +759,12 @@ final class TestCall
$this->testSuite->tests->set($this->testCaseMethod); $this->testSuite->tests->set($this->testCaseMethod);
if (! is_null($testCase = $this->testSuite->tests->get($this->filename))) { if (! is_null($testCase = $this->testSuite->tests->get($this->filename))) {
$testCase->attributes = array_merge($testCase->attributes, $this->testCaseFactoryAttributes); $attributesToMerge = array_filter(
$this->testCaseFactoryAttributes,
fn (Attribute $attributeToMerge): bool => array_filter($testCase->attributes, fn (Attribute $attribute): bool => serialize($attributeToMerge) === serialize($attribute)) === []
);
$testCase->attributes = array_merge($testCase->attributes, $attributesToMerge);
} }
} }
} }

View File

@ -54,7 +54,7 @@ final class UsesCall
} }
/** /**
* @deprecated Use `pest()->theme()->compact()` instead. * @deprecated Use `pest()->printer()->compact()` instead.
*/ */
public function compact(): self public function compact(): self
{ {

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string function version(): string
{ {
return '3.0.1'; return '4.0.3';
} }
function testDirectory(string $file = ''): string function testDirectory(string $file = ''): string

View File

@ -21,7 +21,7 @@ final class Cache implements HandlesArguments
/** /**
* The temporary folder. * The temporary folder.
*/ */
private const TEMPORARY_FOLDER = __DIR__ private const string TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR
.'..' .'..'
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR

View File

@ -21,7 +21,7 @@ final class Configuration implements HandlesArguments, Terminable
/** /**
* The base PHPUnit file. * The base PHPUnit file.
*/ */
public const BASE_PHPUNIT_FILE = __DIR__ public const string BASE_PHPUNIT_FILE = __DIR__
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR
.'..' .'..'
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR
@ -34,7 +34,7 @@ final class Configuration implements HandlesArguments, Terminable
*/ */
public function handleArguments(array $arguments): array public function handleArguments(array $arguments): array
{ {
if ($this->hasArgument('--configuration', $arguments) || $this->hasCustomConfigurationFile()) { if ($this->hasArgument('--configuration', $arguments) || $this->hasArgument('-c', $arguments) || $this->hasCustomConfigurationFile()) {
return $arguments; return $arguments;
} }

View File

@ -17,26 +17,32 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class Coverage implements AddsOutput, HandlesArguments final class Coverage implements AddsOutput, HandlesArguments
{ {
/** private const string COVERAGE_OPTION = 'coverage';
* @var string
*/
private const COVERAGE_OPTION = 'coverage';
/** private const string MIN_OPTION = 'min';
* @var string
*/ private const string EXACTLY_OPTION = 'exactly';
private const MIN_OPTION = 'min';
/** /**
* Whether it should show the coverage or not. * Whether it should show the coverage or not.
*/ */
public bool $coverage = false; public bool $coverage = false;
/**
* Whether it should show the coverage or not.
*/
public bool $compact = false;
/** /**
* The minimum coverage. * The minimum coverage.
*/ */
public float $coverageMin = 0.0; public float $coverageMin = 0.0;
/**
* The exactly coverage.
*/
public ?float $coverageExactly = null;
/** /**
* Creates a new Plugin instance. * Creates a new Plugin instance.
*/ */
@ -51,7 +57,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] as $option) { foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION] as $option) {
if ($original === sprintf('--%s', $option)) { if ($original === sprintf('--%s', $option)) {
return true; return true;
} }
@ -73,6 +79,7 @@ final class Coverage implements AddsOutput, HandlesArguments
$inputs = []; $inputs = [];
$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);
$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)) {
@ -106,6 +113,17 @@ final class Coverage implements AddsOutput, HandlesArguments
$this->coverageMin = (float) $minOption; $this->coverageMin = (float) $minOption;
} }
if ($input->getOption(self::EXACTLY_OPTION) !== null) {
/** @var int|float $exactlyOption */
$exactlyOption = $input->getOption(self::EXACTLY_OPTION);
$this->coverageExactly = (float) $exactlyOption;
}
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
$this->compact = true;
}
return $originals; return $originals;
} }
@ -126,11 +144,23 @@ final class Coverage implements AddsOutput, HandlesArguments
exit(1); exit(1);
} }
$coverage = \Pest\Support\Coverage::report($this->output); $coverage = \Pest\Support\Coverage::report($this->output, $this->compact);
$exitCode = (int) ($coverage < $this->coverageMin); $exitCode = (int) ($coverage < $this->coverageMin);
if ($exitCode === 1) { if ($exitCode === 0 && $this->coverageExactly !== null) {
$comparableCoverage = $this->computeComparableCoverage($coverage);
$comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly);
$exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1;
if ($exitCode === 1) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage not exactly <fg=white;options=bold> %s %%</>, currently <fg=red;options=bold> %s %%</>.",
number_format($this->coverageExactly, 1),
number_format(floor($coverage * 10) / 10, 1),
));
}
} elseif ($exitCode === 1) {
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected <fg=white;options=bold> %s %%</>, currently <fg=red;options=bold> %s %%</>.", "\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected <fg=white;options=bold> %s %%</>, currently <fg=red;options=bold> %s %%</>.",
number_format($this->coverageMin, 1), number_format($this->coverageMin, 1),
@ -143,4 +173,12 @@ final class Coverage implements AddsOutput, HandlesArguments
return $exitCode; return $exitCode;
} }
/**
* Computes the comparable coverage to a percentage with one decimal.
*/
private function computeComparableCoverage(float $coverage): float
{
return floor($coverage * 10) / 10;
}
} }

View File

@ -14,12 +14,12 @@ final class Environment implements HandlesArguments
/** /**
* The continuous integration environment. * The continuous integration environment.
*/ */
public const CI = 'ci'; public const string CI = 'ci';
/** /**
* The local environment. * The local environment.
*/ */
public const LOCAL = 'local'; public const string LOCAL = 'local';
/** /**
* The current environment. * The current environment.

View File

@ -20,12 +20,12 @@ final readonly class Init implements HandlesArguments
/** /**
* The option the triggers the init job. * The option the triggers the init job.
*/ */
private const INIT_OPTION = '--init'; private const string INIT_OPTION = '--init';
/** /**
* The files that will be created. * The files that will be created.
*/ */
private const STUBS = [ private const array STUBS = [
'phpunit.xml.stub' => 'phpunit.xml', 'phpunit.xml.stub' => 'phpunit.xml',
'Pest.php.stub' => 'tests/Pest.php', 'Pest.php.stub' => 'tests/Pest.php',
'TestCase.php.stub' => 'tests/TestCase.php', 'TestCase.php.stub' => 'tests/TestCase.php',
@ -119,6 +119,6 @@ final readonly class Init implements HandlesArguments
*/ */
private function isLaravelInstalled(): bool private function isLaravelInstalled(): bool
{ {
return InstalledVersions::isInstalled('laravel/laravel'); return InstalledVersions::isInstalled('laravel/framework');
} }
} }

View File

@ -5,7 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins; namespace Pest\Plugins;
use Pest\Contracts\Plugins\Terminable; use Pest\Contracts\Plugins\Terminable;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory;
use Pest\PendingCalls\TestCall; use Pest\PendingCalls\TestCall;
use PHPUnit\Framework\Attributes\Group;
/** /**
* @internal * @internal
@ -15,7 +18,7 @@ final class Only implements Terminable
/** /**
* The temporary folder. * The temporary folder.
*/ */
private const TEMPORARY_FOLDER = __DIR__ private const string TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR
.'..' .'..'
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR
@ -23,28 +26,19 @@ final class Only implements Terminable
.DIRECTORY_SEPARATOR .DIRECTORY_SEPARATOR
.'.temp'; .'.temp';
/**
* {@inheritDoc}
*/
public function terminate(): void
{
if (Parallel::isWorker()) {
return;
}
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
/** /**
* Creates the lock file. * Creates the lock file.
*/ */
public static function enable(TestCall $testCall, string $group = '__pest_only'): void public static function enable(TestCall|TestCaseMethodFactory $testCall, string $group = '__pest_only'): void
{ {
$testCall->group($group); if ($testCall instanceof TestCall) {
$testCall->group($group);
} else {
$testCall->attributes[] = new Attribute(
Group::class,
[$group],
);
}
if (Environment::name() === Environment::CI || Parallel::isWorker()) { if (Environment::name() === Environment::CI || Parallel::isWorker()) {
return; return;
@ -88,4 +82,20 @@ final class Only implements Terminable
return file_get_contents($lockFile) ?: '__pest_only'; // @phpstan-ignore-line return file_get_contents($lockFile) ?: '__pest_only'; // @phpstan-ignore-line
} }
/**
* {@inheritDoc}
*/
public function terminate(): void
{
if (Parallel::isWorker()) {
return;
}
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
} }

View File

@ -23,9 +23,9 @@ final class Parallel implements HandlesArguments
{ {
use HandleArguments; use HandleArguments;
private const GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_'; private const string GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
private const HANDLERS = [ private const array HANDLERS = [
Parallel\Handlers\Parallel::class, Parallel\Handlers\Parallel::class,
Parallel\Handlers\Pest::class, Parallel\Handlers\Pest::class,
Parallel\Handlers\Laravel::class, Parallel\Handlers\Laravel::class,
@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments
/** /**
* @var string[] * @var string[]
*/ */
private const UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request']; private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request'];
/** /**
* 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.

View File

@ -18,7 +18,7 @@ final class Parallel implements HandlesArguments
/** /**
* The list of arguments to remove. * The list of arguments to remove.
*/ */
private const ARGS_TO_REMOVE = [ private const array ARGS_TO_REMOVE = [
'--parallel', '--parallel',
'-p', '-p',
'--no-output', '--no-output',

View File

@ -11,6 +11,7 @@ final class CleanConsoleOutput extends ConsoleOutput
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
#[\Override]
protected function doWrite(string $message, bool $newline): void // @pest-arch-ignore-line protected function doWrite(string $message, bool $newline): void // @pest-arch-ignore-line
{ {
if ($this->isOpeningHeadline($message)) { if ($this->isOpeningHeadline($message)) {

View File

@ -59,10 +59,10 @@ final class ResultPrinter
private readonly OutputInterface $output, private readonly OutputInterface $output,
private readonly Options $options private readonly Options $options
) { ) {
$this->printer = new class($this->output) implements Printer $this->printer = new readonly class($this->output) implements Printer
{ {
public function __construct( public function __construct(
private readonly OutputInterface $output, private OutputInterface $output,
) {} ) {}
public function print(string $buffer): void public function print(string $buffer): void

View File

@ -17,8 +17,10 @@ use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Result; use Pest\Result;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\TestRunner\WarningTriggered; use PHPUnit\Event\TestRunner\WarningTriggered;
use PHPUnit\Runner\CodeCoverage; use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; 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;
@ -49,7 +51,7 @@ final class WrapperRunner implements RunnerInterface
/** /**
* The time to sleep between cycles. * The time to sleep between cycles.
*/ */
private const CYCLE_SLEEP = 10000; private const int CYCLE_SLEEP = 10000;
/** /**
* The result printer. * The result printer.
@ -79,7 +81,10 @@ final class WrapperRunner implements RunnerInterface
private array $unexpectedOutputFiles = []; private array $unexpectedOutputFiles = [];
/** @var list<SplFileInfo> */ /** @var list<SplFileInfo> */
private array $testresultFiles = []; private array $resultCacheFiles = [];
/** @var list<SplFileInfo> */
private array $testResultFiles = [];
/** @var list<SplFileInfo> */ /** @var list<SplFileInfo> */
private array $coverageFiles = []; private array $coverageFiles = [];
@ -122,6 +127,9 @@ final class WrapperRunner implements RunnerInterface
$parameters = array_merge($parameters, $options->passthruPhp); $parameters = array_merge($parameters, $options->passthruPhp);
} }
/** @var array<int, non-empty-string> $parameters */
$parameters = $this->handleLaravelHerd($parameters);
$parameters[] = $wrapper; $parameters[] = $wrapper;
$this->parameters = $parameters; $this->parameters = $parameters;
@ -153,6 +161,21 @@ final class WrapperRunner implements RunnerInterface
return $this->complete($result); return $this->complete($result);
} }
/**
* Handles Laravel Herd's debug and coverage modes.
*
* @param array<string> $parameters
* @return array<string>
*/
private function handleLaravelHerd(array $parameters): array
{
if (isset($_ENV['HERD_DEBUG_INI'])) {
return array_merge($parameters, ['-c', $_ENV['HERD_DEBUG_INI']]);
}
return $parameters;
}
private function startWorkers(): void private function startWorkers(): void
{ {
for ($token = 1; $token <= $this->options->processes; $token++) { for ($token = 1; $token <= $this->options->processes; $token++) {
@ -246,7 +269,8 @@ final class WrapperRunner implements RunnerInterface
$this->batches[$token] = 0; $this->batches[$token] = 0;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile; $this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testresultFiles[] = $worker->testresultFile; $this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testResultFiles[] = $worker->testResultFile;
if (isset($worker->junitFile)) { if (isset($worker->junitFile)) {
$this->junitFiles[] = $worker->junitFile; $this->junitFiles[] = $worker->junitFile;
@ -280,37 +304,52 @@ final class WrapperRunner implements RunnerInterface
private function complete(TestResult $testResultSum): int private function complete(TestResult $testResultSum): int
{ {
foreach ($this->testresultFiles as $testresultFile) { foreach ($this->testResultFiles as $testResultFile) {
if (! $testresultFile->isFile()) { if (! $testResultFile->isFile()) {
continue; continue;
} }
$contents = file_get_contents($testresultFile->getPathname()); $contents = file_get_contents($testResultFile->getPathname());
assert($contents !== false); assert($contents !== false);
$testResult = unserialize($contents); $testResult = unserialize($contents);
assert($testResult instanceof TestResult); assert($testResult instanceof TestResult);
/** @var list<AfterLastTestMethodFailed> $failedEvents */
$failedEvents = array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents());
$testResultSum = new TestResult( $testResultSum = new TestResult(
(int) $testResultSum->hasTests() + (int) $testResult->hasTests(), (int) $testResultSum->hasTests() + (int) $testResult->hasTests(),
$testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(), $testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
$testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(), $testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(),
array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()),
array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()), $failedEvents,
array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()), array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()),
array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()), array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()),
array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()), array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()),
array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()), array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()), array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()), array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()), array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()), array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredNoticeEvents(), $testResult->testRunnerTriggeredNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->errors(), $testResult->errors()), array_merge_recursive($testResultSum->errors(), $testResult->errors()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()), array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->notices(), $testResult->notices()), array_merge_recursive($testResultSum->notices(), $testResult->notices()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->warnings(), $testResult->warnings()), array_merge_recursive($testResultSum->warnings(), $testResult->warnings()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()), array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()), array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()), array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(), $testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
); );
@ -328,8 +367,10 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->testMarkedIncompleteEvents(), $testResultSum->testMarkedIncompleteEvents(),
$testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResultSum->testTriggeredPhpunitDeprecationEvents(),
$testResultSum->testTriggeredPhpunitErrorEvents(), $testResultSum->testTriggeredPhpunitErrorEvents(),
$testResultSum->testTriggeredPhpunitNoticeEvents(),
$testResultSum->testTriggeredPhpunitWarningEvents(), $testResultSum->testTriggeredPhpunitWarningEvents(),
$testResultSum->testRunnerTriggeredDeprecationEvents(), $testResultSum->testRunnerTriggeredDeprecationEvents(),
$testResultSum->testRunnerTriggeredNoticeEvents(),
array_values(array_filter( array_values(array_filter(
$testResultSum->testRunnerTriggeredWarningEvents(), $testResultSum->testRunnerTriggeredWarningEvents(),
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found') fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
@ -342,9 +383,20 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->phpNotices(), $testResultSum->phpNotices(),
$testResultSum->phpWarnings(), $testResultSum->phpWarnings(),
$testResultSum->numberOfIssuesIgnoredByBaseline(), $testResultSum->numberOfIssuesIgnoredByBaseline(),
); );
if ($this->options->configuration->cacheResult()) {
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
foreach ($this->resultCacheFiles as $resultCacheFile) {
$resultCache = new DefaultResultCache($resultCacheFile->getPathname());
$resultCache->load();
$resultCacheSum->mergeWith($resultCache);
}
$resultCacheSum->persist();
}
$this->printer->printResults( $this->printer->printResults(
$testResultSum, $testResultSum,
$this->teamcityFiles, $this->teamcityFiles,
@ -357,7 +409,7 @@ final class WrapperRunner implements RunnerInterface
$exitcode = Result::exitCode($this->options->configuration, $testResultSum); $exitcode = Result::exitCode($this->options->configuration, $testResultSum);
$this->clearFiles($this->unexpectedOutputFiles); $this->clearFiles($this->unexpectedOutputFiles);
$this->clearFiles($this->testresultFiles); $this->clearFiles($this->testResultFiles);
$this->clearFiles($this->coverageFiles); $this->clearFiles($this->coverageFiles);
$this->clearFiles($this->junitFiles); $this->clearFiles($this->junitFiles);
$this->clearFiles($this->teamcityFiles); $this->clearFiles($this->teamcityFiles);

View File

@ -34,7 +34,7 @@ final class CompactPrinter
/** /**
* @var array<string, array<int, string>> * @var array<string, array<int, string>>
*/ */
private const LOOKUP_TABLE = [ private const array LOOKUP_TABLE = [
'.' => ['gray', '.'], '.' => ['gray', '.'],
'S' => ['yellow', 's'], 'S' => ['yellow', 's'],
'T' => ['cyan', 't'], 'T' => ['cyan', 't'],
@ -131,14 +131,14 @@ final class CompactPrinter
$status['collected'], $status['collected'],
$status['threshold'], $status['threshold'],
$status['roots'], $status['roots'],
null, 0.00,
null, 0.00,
null, 0.00,
null, 0.00,
null, false,
null, false,
null, false,
null, 0,
); );
$telemetry = new Info( $telemetry = new Info(

177
src/Plugins/Shard.php Normal file
View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Shard implements AddsOutput, HandlesArguments
{
use Concerns\HandleArguments;
private const string SHARD_OPTION = 'shard';
/**
* The shard index and total number of shards.
*
* @var array{
* index: int,
* total: int,
* testsRan: int,
* testsCount: int
* }|null
*/
private static ?array $shard = null;
/**
* Creates a new Plugin instance.
*/
public function __construct(
private readonly OutputInterface $output,
) {
//
}
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
if (! $this->hasArgument('--shard', $arguments)) {
return $arguments;
}
// @phpstan-ignore-next-line
$input = new ArgvInput($arguments);
['index' => $index, 'total' => $total] = self::getShard($input);
$arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument(
"$index/$total",
$arguments,
)));
/** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments);
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
self::$shard = [
'index' => $index,
'total' => $total,
'testsRan' => count($testsToRun),
'testsCount' => count($tests),
];
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
}
/**
* Returns all tests that the test suite would run.
*
* @param list<string> $arguments
* @return list<string>
*/
private function allTests(array $arguments): array
{
$output = (new Process([
'php',
...$this->removeParallelArguments($arguments),
'--list-tests',
]))->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
return array_values(array_unique($matches[1]));
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function removeParallelArguments(array $arguments): array
{
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
}
/**
* Builds the filter argument for the given tests to run.
*/
private function buildFilterArgument(mixed $testsToRun): string
{
return addslashes(implode('|', $testsToRun));
}
/**
* Adds output after the Test Suite execution.
*/
public function addOutput(int $exitCode): int
{
if (self::$shard === null) {
return $exitCode;
}
[
'index' => $index,
'total' => $total,
'testsRan' => $testsRan,
'testsCount' => $testsCount,
] = self::$shard;
$this->output->writeln(sprintf(
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
$index,
$total,
$testsRan,
$testsRan === 1 ? '' : 's',
$testsCount,
));
return $exitCode;
}
/**
* Returns the shard information.
*
* @return array{index: int, total: int}
*/
public static function getShard(InputInterface $input): array
{
if ($input->hasParameterOption('--'.self::SHARD_OPTION)) {
$shard = $input->getParameterOption('--'.self::SHARD_OPTION);
} else {
$shard = null;
}
if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) {
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
}
[$index, $total] = explode('/', $shard);
if (! is_numeric($index) || ! is_numeric($total)) {
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
}
if ($index <= 0 || $total <= 0 || $index > $total) {
throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.');
}
$index = (int) $index;
$total = (int) $total;
return [
'index' => $index,
'total' => $total,
];
}
}

View File

@ -16,7 +16,7 @@ final class Verbose implements HandlesArguments
/** /**
* The list of verbosity levels. * The list of verbosity levels.
*/ */
private const VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q']; private const array VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
/** /**
* {@inheritDoc} * {@inheritDoc}

View File

@ -19,7 +19,7 @@ use function sprintf;
*/ */
final class DatasetsRepository final class DatasetsRepository
{ {
private const SEPARATOR = '>>'; private const string SEPARATOR = '>>';
/** /**
* Holds the datasets. * Holds the datasets.
@ -71,7 +71,7 @@ final class DatasetsRepository
* *
* @throws ShouldNotHappen * @throws ShouldNotHappen
*/ */
public static function get(string $filename, string $description): Closure|array public static function get(string $filename, string $description): Closure|array // @phpstan-ignore-line
{ {
$dataset = self::$withs[$filename.self::SEPARATOR.$description]; $dataset = self::$withs[$filename.self::SEPARATOR.$description];
@ -110,7 +110,6 @@ final class DatasetsRepository
foreach ($datasetCombination as $datasetCombinationElement) { foreach ($datasetCombination as $datasetCombinationElement) {
$partialDescriptions[] = $datasetCombinationElement['label']; $partialDescriptions[] = $datasetCombinationElement['label'];
// @phpstan-ignore-next-line
$values = array_merge($values, $datasetCombinationElement['values']); $values = array_merge($values, $datasetCombinationElement['values']);
} }
@ -221,7 +220,6 @@ final class DatasetsRepository
$result = $tmp; $result = $tmp;
} }
// @phpstan-ignore-next-line
return $result; return $result;
} }

View File

@ -19,8 +19,9 @@ final class SnapshotRepository
* Creates a snapshot repository instance. * Creates a snapshot repository instance.
*/ */
public function __construct( public function __construct(
readonly private string $testsPath, private readonly string $rootPath,
readonly private string $snapshotsPath, private readonly string $testsPath,
private readonly string $snapshotsPath,
) {} ) {}
/** /**
@ -103,7 +104,19 @@ final class SnapshotRepository
*/ */
private function getSnapshotFilename(): string private function getSnapshotFilename(): string
{ {
$relativePath = str_replace($this->testsPath, '', TestSuite::getInstance()->getFilename()); $testFile = TestSuite::getInstance()->getFilename();
if (str_starts_with($testFile, $this->testsPath)) {
// if the test file is in the tests directory
$startPath = $this->testsPath;
} else {
// if the test file is in the app, src, etc. directory
$startPath = $this->rootPath;
}
// relative path: we use substr() and not str_replace() to remove the start path
// for instance, if the $startPath is /app/ and the $testFile is /app/app/tests/Unit/ExampleTest.php, we should only remove the first /app/ from the path
$relativePath = substr($testFile, strlen($startPath));
// remove extension from filename // remove extension from filename
$relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.')); $relativePath = substr($relativePath, 0, (int) strrpos($relativePath, '.'));

View File

@ -4,20 +4,16 @@ declare(strict_types=1);
namespace Pest; namespace Pest;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\Configuration; use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\ShellExitCodeCalculator;
/** /**
* @internal * @internal
*/ */
final class Result final class Result
{ {
private const SUCCESS_EXIT = 0; private const int SUCCESS_EXIT = 0;
private const FAILURE_EXIT = 1;
private const EXCEPTION_EXIT = 2;
/** /**
* If the exit code is different from 0. * If the exit code is different from 0.
@ -40,44 +36,8 @@ final class Result
*/ */
public static function exitCode(Configuration $configuration, TestResult $result): int public static function exitCode(Configuration $configuration, TestResult $result): int
{ {
if ($result->wasSuccessfulIgnoringPhpunitWarnings()) { $shell = new ShellExitCodeCalculator;
if ($configuration->failOnWarning()) {
$warnings = $result->numberOfTestsWithTestTriggeredPhpunitWarningEvents()
+ count($result->warnings())
+ count($result->phpWarnings());
if ($warnings > 0) { return $shell->calculate($configuration, $result);
return self::FAILURE_EXIT;
}
}
if (! $result->hasTestTriggeredPhpunitWarningEvents()) {
return self::SUCCESS_EXIT;
}
}
if ($configuration->failOnEmptyTestSuite() && ResultReflection::numberOfTests($result) === 0) {
return self::FAILURE_EXIT;
}
if ($result->wasSuccessfulIgnoringPhpunitWarnings()) {
if ($configuration->failOnRisky() && $result->hasTestConsideredRiskyEvents()) {
$returnCode = self::FAILURE_EXIT;
}
if ($configuration->failOnIncomplete() && $result->hasTestMarkedIncompleteEvents()) {
$returnCode = self::FAILURE_EXIT;
}
if ($configuration->failOnSkipped() && $result->hasTestSkippedEvents()) {
$returnCode = self::FAILURE_EXIT;
}
}
if ($result->hasTestErroredEvents()) {
return self::EXCEPTION_EXIT;
}
return self::FAILURE_EXIT;
} }
} }

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Pest\Runner\Filter;
use Pest\Contracts\HasPrintableTestCaseName;
use PHPUnit\Framework\Test;
use RecursiveFilterIterator;
use RecursiveIterator;
/**
* @internal
*/
final class EnsureTestCaseIsInitiatedFilter extends RecursiveFilterIterator
{
/**
* @param RecursiveIterator<int, Test> $iterator
*/
public function __construct(RecursiveIterator $iterator)
{
parent::__construct($iterator);
}
/**
* {@inheritdoc}
*/
public function accept(): bool
{
$test = $this->getInnerIterator()->current();
if ($test instanceof HasPrintableTestCaseName) {
/** @phpstan-ignore-next-line */
$test->__initializeTestCase();
}
return true;
}
}

View File

@ -23,19 +23,17 @@ final class EnsureIgnorableTestCasesAreIgnored implements StartedSubscriber
{ {
$reflection = new ReflectionClass(Facade::class); $reflection = new ReflectionClass(Facade::class);
$property = $reflection->getProperty('collector'); $property = $reflection->getProperty('collector');
$property->setAccessible(true);
$collector = $property->getValue(); $collector = $property->getValue();
assert($collector instanceof Collector); assert($collector instanceof Collector);
$reflection = new ReflectionClass($collector); $reflection = new ReflectionClass($collector);
$property = $reflection->getProperty('testRunnerTriggeredWarningEvents'); $property = $reflection->getProperty('testRunnerTriggeredWarningEvents');
$property->setAccessible(true);
/** @var array<int, WarningTriggered> $testRunnerTriggeredWarningEvents */ /** @var array<int, WarningTriggered> $testRunnerTriggeredWarningEvents */
$testRunnerTriggeredWarningEvents = $property->getValue($collector); $testRunnerTriggeredWarningEvents = $property->getValue($collector);
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => $event->message() !== 'No tests found in class "Pest\TestCases\IgnorableTestCase".')); $testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => str_contains($event->message(), 'No tests found in class') === false));
$property->setValue($collector, $testRunnerTriggeredWarningEvents); $property->setValue($collector, $testRunnerTriggeredWarningEvents);
} }

View File

@ -81,4 +81,14 @@ final class Arr
return $results; return $results;
} }
/**
* Returns the value of the last element or false for empty array
*
* @param array<array-key, mixed> $array
*/
public static function last(array $array): mixed
{
return end($array);
}
} }

View File

@ -11,12 +11,9 @@ use Pest\Exceptions\ShouldNotHappen;
*/ */
final class Backtrace final class Backtrace
{ {
/** private const string FILE = 'file';
* @var string
*/
private const FILE = 'file';
private const BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS; private const int BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS;
/** /**
* Returns the current test file. * Returns the current test file.

View File

@ -15,18 +15,18 @@ final class Closure
/** /**
* Binds the given closure to the given "this". * Binds the given closure to the given "this".
* *
*
* @throws ShouldNotHappen * @throws ShouldNotHappen
*/ */
public static function bind(?BaseClosure $closure, ?object $newThis, object|string|null $newScope = 'static'): BaseClosure public static function bind(?BaseClosure $closure, ?object $newThis, object|string|null $newScope = 'static'): BaseClosure
{ {
if ($closure == null) { if (! $closure instanceof \Closure) {
throw ShouldNotHappen::fromMessage('Could not bind null closure.'); throw ShouldNotHappen::fromMessage('Could not bind null closure.');
} }
// @phpstan-ignore-next-line
$closure = BaseClosure::bind($closure, $newThis, $newScope); $closure = BaseClosure::bind($closure, $newThis, $newScope);
if ($closure == false) { if (! $closure instanceof \Closure) {
throw ShouldNotHappen::fromMessage('Could not bind closure.'); throw ShouldNotHappen::fromMessage('Could not bind closure.');
} }

View File

@ -74,7 +74,7 @@ final class Coverage
* Reports the code coverage report to the * Reports the code coverage report to the
* console and returns the result in float. * console and returns the result in float.
*/ */
public static function report(OutputInterface $output): float public static function report(OutputInterface $output, bool $compact = false): float
{ {
if (! file_exists($reportPath = self::getPath())) { if (! file_exists($reportPath = self::getPath())) {
if (self::usingXdebug()) { if (self::usingXdebug()) {
@ -113,6 +113,10 @@ final class Coverage
? '100.0' ? '100.0'
: number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', ''); : number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', '');
if ($percentage === '100.0' && $compact) {
continue;
}
$uncoveredLines = ''; $uncoveredLines = '';
$percentageOfExecutedLinesAsString = $file->percentageOfExecutedLines()->asString(); $percentageOfExecutedLinesAsString = $file->percentageOfExecutedLines()->asString();

View File

@ -11,9 +11,9 @@ use function Pest\testDirectory;
*/ */
final class DatasetInfo final class DatasetInfo
{ {
public const DATASETS_DIR_NAME = 'Datasets'; public const string DATASETS_DIR_NAME = 'Datasets';
public const DATASETS_FILE_NAME = 'Datasets.php'; public const string DATASETS_FILE_NAME = 'Datasets.php';
public static function isInsideADatasetsDirectory(string $file): bool public static function isInsideADatasetsDirectory(string $file): bool
{ {

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
final readonly class Description implements \Stringable
{
/**
* Creates a new Description instance.
*/
public function __construct(private string $description) {}
/**
* Returns the description as a string.
*/
public function __toString(): string
{
return $this->description;
}
}

View File

@ -13,7 +13,7 @@ use Throwable;
*/ */
final class ExceptionTrace final class ExceptionTrace
{ {
private const UNDEFINED_METHOD = 'Call to undefined method P\\'; private const string UNDEFINED_METHOD = 'Call to undefined method P\\';
/** /**
* Ensures the given closure reports the good execution context. * Ensures the given closure reports the good execution context.

View File

@ -15,7 +15,7 @@ final readonly class Exporter
/** /**
* The maximum number of items in an array to export. * The maximum number of items in an array to export.
*/ */
private const MAX_ARRAY_ITEMS = 3; private const int MAX_ARRAY_ITEMS = 3;
/** /**
* Creates a new Exporter instance. * Creates a new Exporter instance.
@ -66,6 +66,7 @@ final readonly class Exporter
$result[] = $context->contains($data[$key]) !== false $result[] = $context->contains($data[$key]) !== false
? '*RECURSION*' ? '*RECURSION*'
// @phpstan-ignore-next-line
: sprintf('[%s]', $this->shortenedRecursiveExport($data[$key], $context)); : sprintf('[%s]', $this->shortenedRecursiveExport($data[$key], $context));
} }

View File

@ -13,7 +13,7 @@ use Throwable;
*/ */
final class HigherOrderMessage final class HigherOrderMessage
{ {
public const UNDEFINED_METHOD = 'Method %s does not exist'; public const string UNDEFINED_METHOD = 'Method %s does not exist';
/** /**
* An optional condition that will determine if the message will be executed. * An optional condition that will determine if the message will be executed.
@ -50,14 +50,13 @@ final class HigherOrderMessage
} }
if ($this->hasHigherOrderCallable()) { if ($this->hasHigherOrderCallable()) {
/* @phpstan-ignore-next-line */
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments); return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
} }
try { try {
return is_array($this->arguments) return is_array($this->arguments)
? Reflection::call($target, $this->name, $this->arguments) ? Reflection::call($target, $this->name, $this->arguments)
: $target->{$this->name}; /* @phpstan-ignore-line */ : $target->{$this->name};
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
Reflection::setPropertyValue($throwable, 'file', $this->filename); Reflection::setPropertyValue($throwable, 'file', $this->filename);
Reflection::setPropertyValue($throwable, 'line', $this->line); Reflection::setPropertyValue($throwable, 'line', $this->line);
@ -65,7 +64,6 @@ final class HigherOrderMessage
if ($throwable->getMessage() === $this->getUndefinedMethodMessage($target, $this->name)) { if ($throwable->getMessage() === $this->getUndefinedMethodMessage($target, $this->name)) {
/** @var ReflectionClass<TValue> $reflection */ /** @var ReflectionClass<TValue> $reflection */
$reflection = new ReflectionClass($target); $reflection = new ReflectionClass($target);
/* @phpstan-ignore-next-line */
$reflection = $reflection->getParentClass() ?: $reflection; $reflection = $reflection->getParentClass() ?: $reflection;
Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $this->name)); Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $this->name));
} }
@ -96,10 +94,6 @@ final class HigherOrderMessage
private function getUndefinedMethodMessage(object $target, string $methodName): string private function getUndefinedMethodMessage(object $target, string $methodName): string
{ {
if (\PHP_MAJOR_VERSION >= 8) { return sprintf(self::UNDEFINED_METHOD, sprintf('%s::%s()', $target::class, $methodName));
return sprintf(self::UNDEFINED_METHOD, sprintf('%s::%s()', $target::class, $methodName));
}
return sprintf(self::UNDEFINED_METHOD, $methodName);
} }
} }

View File

@ -40,7 +40,6 @@ final class HigherOrderMessageCollection
public function chain(object $target): void public function chain(object $target): void
{ {
foreach ($this->messages as $message) { foreach ($this->messages as $message) {
// @phpstan-ignore-next-line
$target = $message->call($target) ?? $target; $target = $message->call($target) ?? $target;
} }
} }

View File

@ -26,7 +26,7 @@ final class HigherOrderTapProxy
*/ */
public function __set(string $property, mixed $value): void public function __set(string $property, mixed $value): void
{ {
$this->target->{$property} = $value; // @phpstan-ignore-line $this->target->{$property} = $value;
} }
/** /**
@ -37,7 +37,7 @@ final class HigherOrderTapProxy
public function __get(string $property) public function __get(string $property)
{ {
if (property_exists($this->target, $property)) { if (property_exists($this->target, $property)) {
return $this->target->{$property}; // @phpstan-ignore-line return $this->target->{$property};
} }
$className = (new ReflectionClass($this->target))->getName(); $className = (new ReflectionClass($this->target))->getName();

View File

@ -34,8 +34,6 @@ final class Reflection
try { try {
$reflectionMethod = $reflectionClass->getMethod($method); $reflectionMethod = $reflectionClass->getMethod($method);
$reflectionMethod->setAccessible(true);
return $reflectionMethod->invoke($object, ...$args); return $reflectionMethod->invoke($object, ...$args);
} catch (ReflectionException $exception) { } catch (ReflectionException $exception) {
if (method_exists($object, '__call')) { if (method_exists($object, '__call')) {
@ -113,8 +111,6 @@ final class Reflection
} }
} }
$reflectionProperty->setAccessible(true);
return $reflectionProperty->getValue($object); return $reflectionProperty->getValue($object);
} }
@ -144,8 +140,6 @@ final class Reflection
} }
} }
} }
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($object, $value); $reflectionProperty->setValue($object, $value);
} }

101
src/Support/Shell.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Illuminate\Support\Env;
use Laravel\Tinker\ClassAliasAutoloader;
use Pest\TestSuite;
use Psy\Configuration;
use Psy\Shell as PsyShell;
use Psy\VersionUpdater\Checker;
/**
* @internal
*/
final class Shell
{
/**
* Creates a new interactive shell.
*/
public static function open(): void
{
$config = new Configuration;
$config->setUpdateCheck(Checker::NEVER);
$config->getPresenter()->addCasters(self::casters());
$shell = new PsyShell($config);
$loader = self::tinkered($shell);
try {
$shell->run();
} finally {
$loader?->unregister(); // @phpstan-ignore-line
}
}
/**
* Returns the casters for the Psy Shell.
*
* @return array<string, callable>
*/
private static function casters(): array
{
$casters = [
'Illuminate\Support\Collection' => 'Laravel\Tinker\TinkerCaster::castCollection',
'Illuminate\Support\HtmlString' => 'Laravel\Tinker\TinkerCaster::castHtmlString',
'Illuminate\Support\Stringable' => 'Laravel\Tinker\TinkerCaster::castStringable',
];
if (class_exists('Illuminate\Database\Eloquent\Model')) {
$casters['Illuminate\Database\Eloquent\Model'] = 'Laravel\Tinker\TinkerCaster::castModel';
}
if (class_exists('Illuminate\Process\ProcessResult')) {
$casters['Illuminate\Process\ProcessResult'] = 'Laravel\Tinker\TinkerCaster::castProcessResult';
}
if (class_exists('Illuminate\Foundation\Application')) {
$casters['Illuminate\Foundation\Application'] = 'Laravel\Tinker\TinkerCaster::castApplication';
}
if (function_exists('app') === false) {
return $casters; // @phpstan-ignore-line
}
$config = app()->make('config');
return array_merge($casters, (array) $config->get('tinker.casters', []));
}
/**
* Tinkers the current shell, if the Tinker package is available.
*/
private static function tinkered(PsyShell $shell): ?object
{
if (function_exists('app') === false
|| ! class_exists(Env::class)
|| ! class_exists(ClassAliasAutoloader::class)
) {
return null;
}
$path = Env::get('COMPOSER_VENDOR_DIR', app()->basePath().DIRECTORY_SEPARATOR.'vendor');
$path .= '/composer/autoload_classmap.php';
if (! file_exists($path)) {
$path = TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'composer'.DIRECTORY_SEPARATOR.'autoload_classmap.php';
}
$config = app()->make('config');
return ClassAliasAutoloader::register(
$shell, $path, $config->get('tinker.alias', []), $config->get('tinker.dont_alias', [])
);
}
}

View File

@ -30,6 +30,7 @@ final class StateGenerator
$testResultEvent->throwable() $testResultEvent->throwable()
)); ));
} else { } else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent)); $state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
} }
} }

View File

@ -13,12 +13,9 @@ final class Str
* Pool of alpha-numeric characters for generating (unsafe) random strings * Pool of alpha-numeric characters for generating (unsafe) random strings
* from. * from.
*/ */
private const POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; private const string POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
/** private const string PREFIX = '__pest_evaluable_';
* @var string
*/
private const PREFIX = '__pest_evaluable_';
/** /**
* Create a (unsecure & non-cryptographically safe) random alpha-numeric * Create a (unsecure & non-cryptographically safe) random alpha-numeric
@ -103,10 +100,14 @@ final class Str
/** /**
* Creates a describe block as `$describeDescription` → `$testDescription` format. * Creates a describe block as `$describeDescription` → `$testDescription` format.
*
* @param array<int, Description> $describeDescriptions
*/ */
public static function describe(string $describeDescription, string $testDescription): string public static function describe(array $describeDescriptions, string $testDescription): string
{ {
return sprintf('`%s` → %s', $describeDescription, $testDescription); $descriptionComponents = [...$describeDescriptions, $testDescription];
return sprintf(str_repeat('`%s` → ', count($describeDescriptions)).'%s', ...$descriptionComponents);
} }
/** /**
@ -116,4 +117,14 @@ final class Str
{ {
return (bool) filter_var($value, FILTER_VALIDATE_URL); return (bool) filter_var($value, FILTER_VALIDATE_URL);
} }
/**
* Converts the given `$target` to a URL-friendly "slug".
*/
public static function slugify(string $target): string
{
$target = preg_replace('/[^a-zA-Z0-9]+/', '-', $target);
return strtolower(trim((string) $target, '-'));
}
} }

View File

@ -78,6 +78,7 @@ final class TestSuite
$this->afterAll = new AfterAllRepository; $this->afterAll = new AfterAllRepository;
$this->rootPath = (string) realpath($rootPath); $this->rootPath = (string) realpath($rootPath);
$this->snapshots = new SnapshotRepository( $this->snapshots = new SnapshotRepository(
$this->rootPath,
implode(DIRECTORY_SEPARATOR, [$this->rootPath, $this->testPath]), implode(DIRECTORY_SEPARATOR, [$this->rootPath, $this->testPath]),
implode(DIRECTORY_SEPARATOR, ['.pest', 'snapshots']), implode(DIRECTORY_SEPARATOR, ['.pest', 'snapshots']),
); );
@ -101,7 +102,7 @@ final class TestSuite
} }
if (! self::$instance instanceof self) { if (! self::$instance instanceof self) {
Panic::with(new InvalidPestCommand); throw new InvalidPestCommand;
} }
return self::$instance; return self::$instance;
@ -119,7 +120,7 @@ final class TestSuite
assert($this->test instanceof TestCase); assert($this->test instanceof TestCase);
$description = str_replace('__pest_evaluable_', '', $this->test->name()); $description = str_replace('__pest_evaluable_', '', $this->test->name());
$datasetAsString = str_replace('__pest_evaluable_', '', Str::evaluable($this->test->dataSetAsStringWithData())); $datasetAsString = str_replace('__pest_evaluable_', '', Str::evaluable($this->test->dataSetAsString()));
return str_replace(' ', '_', $description.$datasetAsString); return str_replace(' ', '_', $description.$datasetAsString);
} }

View File

@ -1,31 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php" bootstrap="vendor/autoload.php"
colors="true" colors="true"
> >
<testsuites> <testsuites>
<testsuite name="Unit"> <testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory> <directory>tests/Unit</directory>
</testsuite> </testsuite>
<testsuite name="Feature"> <testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory> <directory>tests/Feature</directory>
</testsuite> </testsuite>
</testsuites> </testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/> <env name="CACHE_STORE" value="array"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/> --> <!-- <env name="DB_CONNECTION" value="sqlite"/> -->
<!-- <env name="DB_DATABASE" value=":memory:"/> --> <!-- <env name="DB_DATABASE" value=":memory:"/> -->
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
</php> </php>
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
</source>
</phpunit> </phpunit>

View File

@ -11,7 +11,7 @@
| |
*/ */
// pest()->extend(Tests\TestCase::class)->in('Feature'); pest()->extend(Tests\TestCase::class)->in('Feature');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php" bootstrap="vendor/autoload.php"
colors="true" colors="true"
> >
@ -11,8 +11,8 @@
</testsuites> </testsuites>
<source> <source>
<include> <include>
<directory suffix=".php">./app</directory> <directory>app</directory>
<directory suffix=".php">./src</directory> <directory>src</directory>
</include> </include>
</source> </source>
</phpunit> </phpunit>

View File

@ -0,0 +1,35 @@
<?php
use Pest\TestSuite;
beforeEach(function () {
$this->snapshotable = <<<'HTML'
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>
HTML;
});
test('pass with dataset', function ($data) {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
expect($filename)->toStartWith('tests/.pest/snapshots-external/')
->toEndWith('pass_with_dataset_with_data_set_____my_datas_set_value___.snap')
->and($this->snapshotable)->toMatchSnapshot();
})->with(['my-datas-set-value']);
describe('within describe', function () {
test('pass with dataset', function ($data) {
TestSuite::getInstance()->snapshots->save($this->snapshotable);
[$filename] = TestSuite::getInstance()->snapshots->get();
expect($filename)->toStartWith('tests/.pest/snapshots-external/')
->toEndWith('pass_with_dataset_with_data_set_____my_datas_set_value___.snap')
->and($this->snapshotable)->toMatchSnapshot();
});
})->with(['my-datas-set-value']);

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="container">
<div class="row">
<div class="col-md-12">
<h1>Snapshot</h1>
</div>
</div>
</div>

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