mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
684 Commits
v3.0.7
..
92c7677c6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 92c7677c6e | |||
| 872f0a50c2 | |||
| 668809bc75 | |||
| 8d8f45c843 | |||
| 086b3e9107 | |||
| 43fe26f324 | |||
| 865c1e5113 | |||
| 19eed8d581 | |||
| ad1850b110 | |||
| 585de259a2 | |||
| 953664dce0 | |||
| 1dd959848c | |||
| 047753c836 | |||
| b63626a94d | |||
| ae080becd6 | |||
| aa86304794 | |||
| 75963ea973 | |||
| 5d0ceb1a5d | |||
| 6f0c0536a4 | |||
| 6b2cd358e8 | |||
| e09e1be76d | |||
| 759ac63139 | |||
| 333f469efb | |||
| c4ec925aa5 | |||
| 5c8cfed483 | |||
| 962510b7e3 | |||
| e9c268a584 | |||
| fa91fcf744 | |||
| c6073511a2 | |||
| b5e1b83bb4 | |||
| 1c11595504 | |||
| cd0e921158 | |||
| f8c4c061f8 | |||
| 81eacd79fd | |||
| 1ef680c75d | |||
| 820fa08313 | |||
| 9e79e491e2 | |||
| 1b83b8fcda | |||
| ff4aba2ffa | |||
| feaac1ab7e | |||
| bbc6ada21e | |||
| 3927dbfcdf | |||
| b52bbb4cca | |||
| 52819501eb | |||
| 7b18287374 | |||
| 33e3bf2888 | |||
| 72ab506d7b | |||
| 5dc49a71d6 | |||
| 1adb484d3f | |||
| aadf7e92a6 | |||
| df42b9ed7b | |||
| 632ae79c8b | |||
| b5fed82883 | |||
| 145e20db75 | |||
| ee2e97e932 | |||
| b561236325 | |||
| e685179b7e | |||
| ac8feafdcc | |||
| b49cd150c9 | |||
| 5cfb4133bf | |||
| 3c8bae5f05 | |||
| b2998bc69e | |||
| 3876093cd2 | |||
| 932f8bcc07 | |||
| d393799d2a | |||
| 0d7814ca16 | |||
| 8467c64c22 | |||
| 97714a7088 | |||
| afb582616d | |||
| 15e9b6a507 | |||
| 520ce29376 | |||
| 87882a8561 | |||
| 77ef7e0df1 | |||
| 12100dd901 | |||
| 20c12d006e | |||
| 3aab02d2eb | |||
| 553aac65e6 | |||
| be49a3ce18 | |||
| 774a340400 | |||
| 04c9d41895 | |||
| dfb7b870af | |||
| 37821e1108 | |||
| cd711a25d5 | |||
| eee60c9e11 | |||
| c40c8dbc24 | |||
| 2d93c9c373 | |||
| 3d5bba93f8 | |||
| e5ab3af05b | |||
| 79bc7a8257 | |||
| 40b88b62ef | |||
| e3361bc321 | |||
| fc48c1bd1e | |||
| 92e76eb5ab | |||
| da726beffc | |||
| 4ef12b9aac | |||
| bd22f478b8 | |||
| eeaac34cf6 | |||
| b9b07d8983 | |||
| 6aa7d2f891 | |||
| 4d550cecfd | |||
| 1c21a7647a | |||
| d649de1988 | |||
| 783ca4bcd6 | |||
| ba07497219 | |||
| 34695843b3 | |||
| 1ca021dea6 | |||
| d17be9decd | |||
| b828ddcec7 | |||
| f859bb179d | |||
| 2fc75cfcf0 | |||
| 6cc48f63f8 | |||
| e0419d1328 | |||
| faa6988801 | |||
| c12247fafd | |||
| 29b4452443 | |||
| 1b168aba1c | |||
| 6aabd977cd | |||
| a882543c53 | |||
| c250b9da4f | |||
| 46bc3dc628 | |||
| d3ce498b8a | |||
| e1a4b98b71 | |||
| 9afbcd5c18 | |||
| 75593b6454 | |||
| 89590d6120 | |||
| fb0978c9bf | |||
| a3796daa42 | |||
| e3004db666 | |||
| 99cc4e0146 | |||
| a47e6f8fef | |||
| 536d79f765 | |||
| 65c0fbc528 | |||
| 9e4cf4b665 | |||
| 7bea819978 | |||
| 4280233b40 | |||
| d6db3a8a20 | |||
| 51c8ce4df6 | |||
| 5b8393b925 | |||
| e4d9b61fdf | |||
| e2d940cd53 | |||
| 380ccd30b4 | |||
| 31c200716d | |||
| 6add4da543 | |||
| 8ddcd3e853 | |||
| e3e178fd94 | |||
| 7b1ec9f003 | |||
| 1e48c5d473 | |||
| d00ec95dd9 | |||
| 89f3d6cb39 | |||
| a07a2e512a | |||
| 57eecb2b3d | |||
| 9f804dc954 | |||
| 7cbad4c589 | |||
| 5cae93b059 | |||
| df829ad19d | |||
| 635460653c | |||
| 1aa80dc398 | |||
| 8a14056111 | |||
| f247dd8e7b | |||
| 1c7c9754fd | |||
| 5f37939fda | |||
| 28305fcb7a | |||
| 5242803694 | |||
| 925935a7e8 | |||
| 460401c379 | |||
| 348b439172 | |||
| a4e77766c5 | |||
| 4a8c2d7d78 | |||
| 7d51601120 | |||
| 631bbe318b | |||
| 9b7c15d5b6 | |||
| 872796bd9b | |||
| c38d32ae86 | |||
| 6407c4f78f | |||
| 6e1bf63f6a | |||
| 1d3e8bb5dd | |||
| 3cc9b169e3 | |||
| c4911d046b | |||
| d0295f6168 | |||
| 21efbc3107 | |||
| e59b99cd73 | |||
| bf48e20880 | |||
| 53db68e005 | |||
| 34f1e9a7f2 | |||
| 57fd5ce042 | |||
| 3bcabfb63b | |||
| aa3a7c303a | |||
| 5c08a135f7 | |||
| 6e0e030d71 | |||
| b2c07561e7 | |||
| 97600b6f0b | |||
| 8a51f15d65 | |||
| a349f53964 | |||
| a725e774c0 | |||
| bed5e5b54a | |||
| 45b1d4ce20 | |||
| d106b70766 | |||
| 6ac6c1518e | |||
| fda515a17f | |||
| 0a97d3a288 | |||
| 3802fa80e6 | |||
| 5c3cbc14d2 | |||
| 6b9c768172 | |||
| 4a2fc179ae | |||
| b5bb2139dc | |||
| 07416a3c61 | |||
| 30b94e3034 | |||
| be34eecb2f | |||
| 5d9f95f8d4 | |||
| 48b70a03d5 | |||
| 4b8642b972 | |||
| 8711d51eac | |||
| 58dfb6da64 | |||
| d7735d1faa | |||
| 6b59166f3c | |||
| 3a26028d17 | |||
| 3c91bf4ad2 | |||
| 6a434be0f6 | |||
| f355b99bbf | |||
| 95a00341e9 | |||
| 466259646d | |||
| 00f8d56083 | |||
| ca2dca592d | |||
| 405d8d4406 | |||
| b944ee5841 | |||
| f4e22dcafe | |||
| 339c1e8cac | |||
| d4c7362132 | |||
| 81bfdbf8fe | |||
| f45cbf43c5 | |||
| b9088d23fb | |||
| 7250185423 | |||
| e457eb0e9c | |||
| 48357c6f30 | |||
| b46f051550 | |||
| 3d3c5d41ac | |||
| caabebf2a1 | |||
| 470a5833d4 | |||
| c1feefbb9e | |||
| e876dba8ba | |||
| d9c18f9c02 | |||
| 660b57b365 | |||
| 68527c996f | |||
| c6a42a2b28 | |||
| fcf5c27914 | |||
| 856a370032 | |||
| e24882c486 | |||
| 51fc380789 | |||
| f6609f4039 | |||
| 2941f9821f | |||
| ed399af43e | |||
| 0d66dc4322 | |||
| 7e4280bf83 | |||
| a5915b16ab | |||
| 1476b529a1 | |||
| 2892341c28 | |||
| 59e781e77b | |||
| 55a3394f8c | |||
| 0d99c33c4e | |||
| adc5aae6f8 | |||
| 980667e845 | |||
| 8c849c5f40 | |||
| 47f1fc2d94 | |||
| 9c8033d60c | |||
| 42d1092a9e | |||
| c7e32f5d33 | |||
| d379128cc4 | |||
| f09d6f2064 | |||
| 494cc6e2a4 | |||
| f52a455773 | |||
| 184f5d2742 | |||
| 1d81069a2a | |||
| 4b9bb77b54 | |||
| c440031e28 | |||
| 18bbca748f | |||
| bff44562a9 | |||
| 9ebb990f96 | |||
| f142aad8ad | |||
| cabff738f7 | |||
| 0746173a32 | |||
| 74a28d4f5e | |||
| 6053e15d00 | |||
| 87db0b4847 | |||
| 6ba373a772 | |||
| 945d476409 | |||
| a8cf0fe2cb | |||
| 2ae072bb95 | |||
| 59d066950c | |||
| 0dd1aa72ef | |||
| 4e03cd3edb | |||
| eeab24e2bb | |||
| 9b64d5425a | |||
| 0acab1cbb4 | |||
| e616eab9fb | |||
| 7cbb1fcdb2 | |||
| cb5f6e1bd2 | |||
| 985dadd934 | |||
| 10aee6045c | |||
| 4ac14b2528 | |||
| 2d649d765f | |||
| 4fb4908570 | |||
| 13c322bab3 | |||
| 3855249ce9 | |||
| f528bd8427 | |||
| acd8aafa63 | |||
| e8d630e774 | |||
| b6385dc865 | |||
| 02dc8d7bcc | |||
| 729f18a152 | |||
| bdf60cea91 | |||
| 3a8ee8291c | |||
| 654cb726c9 | |||
| bce26aeaad | |||
| 5948bcd71e | |||
| 89006d83a9 | |||
| a8e974d64a | |||
| 617b074049 | |||
| 2eea71a664 | |||
| 4b5374d507 | |||
| 9085561ece | |||
| b71bfc513a | |||
| 75938ac9eb | |||
| e766825f5b | |||
| 8a83a1a1a9 | |||
| e63a886f98 | |||
| 109bb22c5e | |||
| 89dd212d84 | |||
| cd07c6d966 | |||
| 8dddb47ad5 | |||
| 3a6c2fab37 | |||
| 281dbf6cf4 | |||
| 40c8429058 | |||
| 8dd650fd05 | |||
| d9d46c73f8 | |||
| fbca346d7c | |||
| 3f13bca0f7 | |||
| d3acb1c56a | |||
| e601e6df31 | |||
| 6fdbca1226 | |||
| 54359b895f | |||
| e44c554a0b | |||
| 44c04bfce1 | |||
| 271c680d3c | |||
| 9797a71dbc | |||
| c1a54df233 | |||
| 4a1d8d27b8 | |||
| 0f6924984c | |||
| ce05ee9aad | |||
| 3d2ebdb273 | |||
| 668ca9f5de | |||
| f47b74445b | |||
| 6c42e7f4ea | |||
| be3ff37517 | |||
| a087555383 | |||
| 4b50cb486d | |||
| f7175ecfd7 | |||
| 07737bc0b2 | |||
| f659a45311 | |||
| e6ab897594 | |||
| a753b41409 | |||
| 1a4c06bd6e | |||
| 12c1da29ee | |||
| 5d42e8fe3a | |||
| 9d17b872dd | |||
| 2a80101f42 | |||
| f7015fe59c | |||
| 7281e0ded7 | |||
| 1675dd1d41 | |||
| fa27c8daef | |||
| f0a08f0503 | |||
| 2c040c5b1f | |||
| a9ce1fd739 | |||
| 3533356262 | |||
| 4aa41d0b14 | |||
| e4ed60085c | |||
| e2b119655d | |||
| fcf5baf0a9 | |||
| df7b6c8454 | |||
| 5de8693e3b | |||
| 7d80f1d20e | |||
| b3119cc120 | |||
| 4e294edf76 | |||
| f96a1b2786 | |||
| a49cf7edc5 | |||
| b0f6a74cb6 | |||
| aaa226f6a6 | |||
| 69cb752d02 | |||
| cf00e58b7d | |||
| 1f39b28e2c | |||
| 9fcbca69d4 | |||
| b081584ab6 | |||
| 6966802afc | |||
| c61dcad42b | |||
| ec3e0b2d33 | |||
| c3620840b4 | |||
| 10a19f16ba | |||
| a956de5446 | |||
| 3a4329ddc7 | |||
| e6f511302b | |||
| dd01229d7b | |||
| c7e4efcea4 | |||
| df3205e814 | |||
| bc57a84e77 | |||
| bc39830d8a | |||
| 3a566b100e | |||
| 9fe61e0e56 | |||
| e86bec3e68 | |||
| 58b8f3cc5d | |||
| c157b661f2 | |||
| be90610f17 | |||
| 1701a306c3 | |||
| 064ab3fc2e | |||
| 44e315df98 | |||
| 62694c14b9 | |||
| 7c43c1c583 | |||
| 6a96aed654 | |||
| b1c997a869 | |||
| b4172e2c2e | |||
| ae419afd36 | |||
| 27aa305897 | |||
| 0e7c2abe8b | |||
| f5820bd670 | |||
| 41fd831153 | |||
| 51340439e8 | |||
| 1a39826935 | |||
| bd5fed9e12 | |||
| 26345fd9f4 | |||
| ae1da79ac1 | |||
| 00990efc97 | |||
| 477d20a54f | |||
| b7b16096db | |||
| 4105e33c39 | |||
| 08b09f2e98 | |||
| b0fab7e437 | |||
| 8e3444e1db | |||
| dc9a1e8ace | |||
| fc7a4182b5 | |||
| b7406938ac | |||
| 314caabd1d | |||
| 65cabf91b1 | |||
| f91c6c1e1e | |||
| 843dbbf18a | |||
| 47fb1d7763 | |||
| 639df4cb43 | |||
| e54e4a0178 | |||
| 7749775f50 | |||
| f11f3aa0a4 | |||
| 33817013fe | |||
| 7f11ace329 | |||
| 3d776f1f20 | |||
| d5ced0a5ca | |||
| af1e214be4 | |||
| 7f9b50974a | |||
| cd5272d8cc | |||
| a7b2039175 | |||
| 72cf695554 | |||
| 50960a96e9 | |||
| 507df757a1 | |||
| 8722b3fc3c | |||
| 19eca6e338 | |||
| 6b523d6963 | |||
| a350545803 | |||
| 71c2e97c9f | |||
| 98a12012bf | |||
| 027f4e4832 | |||
| 92523a6f39 | |||
| ed38fb644f | |||
| 39b66bf01d | |||
| 165c879fe6 | |||
| 4c8bf4b2fd | |||
| 1ee36f584d | |||
| 1b0a846a81 | |||
| e3e518747f | |||
| 0b96b8f630 | |||
| 711a60c2db | |||
| e7132fa012 | |||
| 3b72bbd7fe | |||
| 273edb864c | |||
| fcb60f3c4a | |||
| 91bb7589e2 | |||
| e524bf5f73 | |||
| 27414ce19f | |||
| fbc9e704e2 | |||
| ee6b3ed062 | |||
| 4c88590b89 | |||
| 66e59efec6 | |||
| f692be3637 | |||
| 127ad618d3 | |||
| da04ba62a8 | |||
| d187566e63 | |||
| 3e86e158b2 | |||
| d6c6489e93 | |||
| ee70a3cfea | |||
| 7a6f33f139 | |||
| 55218bcf78 | |||
| e29302300f | |||
| 2a47b514ec | |||
| 222ed174bc | |||
| 2aa32569f0 | |||
| 1d8d1a046f | |||
| 3d9ceb1cf2 | |||
| 520a5fe29d | |||
| de4409e368 | |||
| 6d6e4e040f | |||
| aac08629f7 | |||
| fe27012bbc | |||
| f9901245f1 | |||
| 21e22decf3 | |||
| e513f76ea9 | |||
| be9c95e3bc | |||
| 9172721ce8 | |||
| 924dc016cc | |||
| f49b91ec0d | |||
| 516ace85b4 | |||
| f9814793dd | |||
| 00572f5f8e | |||
| fb282b184e | |||
| e0695a13cb | |||
| 8f810bf2a2 | |||
| 84636cee96 | |||
| 0355119afc | |||
| 9d0410ee0b | |||
| 0d148c2a67 | |||
| 0f1e87c726 | |||
| 73bf579da3 | |||
| 5def62018b | |||
| d8e1b27491 | |||
| 2ff4713968 | |||
| 3f27352560 | |||
| af3fdceddb | |||
| 3faeede1ef | |||
| 0bc3219a2b | |||
| a22013a7d3 | |||
| 7fc69033f8 | |||
| ef76c04dbe | |||
| 7d77bbf1bb | |||
| 163479ae60 | |||
| c3bfdf130e | |||
| 8c403a57c2 | |||
| 97c136cd94 | |||
| d6cbd12d8b | |||
| 49bf00024f | |||
| dd44ac4195 | |||
| 5d2aafd2a3 | |||
| 0fc9d4dfe0 | |||
| 02b1ffb334 | |||
| c62cc3fef0 | |||
| 909d778da3 | |||
| 7711a52fe9 | |||
| 99c9f4e5d8 | |||
| a310796165 | |||
| db9243ca2e | |||
| 635e3b4c41 | |||
| 791734a29c | |||
| 8cfb0acf46 | |||
| bf67407ba5 | |||
| efdc84e115 | |||
| d1608bf33d | |||
| 4f6140fdb1 | |||
| 442a58d07f | |||
| 19e9267021 | |||
| c6244a8712 | |||
| eed68f2840 | |||
| 6080f51a0b | |||
| e0f07be017 | |||
| 42e1b9f17f | |||
| 0171617c1d | |||
| 2e11e9e65d | |||
| 4969526ef2 | |||
| d7b1c36fdd | |||
| 003fc96e8f | |||
| f68d11ccae | |||
| e46d499384 | |||
| 490f321a0d | |||
| 174645caa2 | |||
| ed70c9dc2b | |||
| 157a753d87 | |||
| 2c3a53f6cd | |||
| 0bdaef29e9 | |||
| 1ad30a97b3 | |||
| a5317c5640 | |||
| 66ceb64faa | |||
| fa4098db8d | |||
| 4a987d3d5c | |||
| 4079a08f5f | |||
| e4aab77a34 | |||
| 50ff347b59 | |||
| b5b8fab09b | |||
| c4c9e915f4 | |||
| e834527db2 | |||
| 23f130b0f9 | |||
| 0cb8c42497 | |||
| fe4b5e5e1f | |||
| 8ee9d66d80 | |||
| 7760d945bb | |||
| 709ecb1ba2 | |||
| 6afb36519d | |||
| 150bb9478d | |||
| bf3178473d | |||
| d2eb94d723 | |||
| 9688b83a3d | |||
| 675372c794 | |||
| c18636b3d5 | |||
| 1ac594bdf0 | |||
| 145294a4a3 | |||
| c2cabaeae6 | |||
| 918a8fc169 | |||
| 5d32dd0641 | |||
| 982353fb38 | |||
| 2eefa8b88d | |||
| 787d5492ac | |||
| 06a0bd9b0b | |||
| 5331b44a18 | |||
| 91afc81222 | |||
| 179d46ce97 | |||
| fa2bc1e536 | |||
| eaeb133c77 | |||
| cf57ea1f94 | |||
| 0b7f4f2384 | |||
| 2903a7e621 | |||
| b8964375c7 | |||
| bdcb883829 | |||
| 8a7e7f39ef | |||
| 53c94600cb | |||
| 67f217852c | |||
| 1bad148487 | |||
| e24f137b8e | |||
| 6d9189f3f5 | |||
| 6968094e2b | |||
| 9510d4a2f9 | |||
| cd2eb3504b | |||
| 7c639cdbbd | |||
| 1513ede73b | |||
| 8c65197881 | |||
| a6cd83665c | |||
| 0c57142c03 | |||
| 3f65af9fdf | |||
| 42d89814e3 | |||
| 1e3156a5b6 | |||
| 97713c0832 | |||
| 62b0e3c9df | |||
| 647de2f1cf | |||
| 0a7bff0d24 | |||
| 7618434580 | |||
| dd7d150caa | |||
| 1e0bb88b73 | |||
| 83b76d7c2e | |||
| 5a870b3940 | |||
| 1115c64186 | |||
| e38a271ca2 | |||
| 43703ab40a | |||
| 86452765a4 | |||
| b8a1b7e5cc | |||
| 5fe79d9c18 | |||
| 2744da4292 | |||
| 87f4e5e7b3 | |||
| bb3decf3cc | |||
| 4e2987d438 | |||
| a25158bce8 | |||
| 49e77b1d4c | |||
| 989e43d1a0 | |||
| 7cd42aafd8 | |||
| 48a1de273f | |||
| 970e16e949 | |||
| 432ff221c6 | |||
| a55da85dd2 | |||
| f291cd1603 | |||
| 5de0c2254a | |||
| b98ce0ced3 | |||
| 28772c2609 | |||
| 452ffaf8df | |||
| e8338405b5 | |||
| 1b014e4b18 | |||
| 034715e8b1 | |||
| 669dc0da71 | |||
| 92bc1decd9 | |||
| e3bfcbe5f1 | |||
| ba7eb70a5d | |||
| 74ff3b8cd9 | |||
| ab0b4a1b4e | |||
| 169b76458e | |||
| 668685498f | |||
| bab193e7e1 | |||
| f720be862e |
@@ -0,0 +1,13 @@
|
||||
# Security Policy
|
||||
|
||||
**PLEASE DON'T DISCLOSE SECURITY-RELATED ISSUES PUBLICLY, [SEE BELOW](#reporting-a-vulnerability).**
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Pest, please report it privately using one of the following channels:
|
||||
|
||||
1. **GitHub Private Vulnerability Reporting** (preferred) — go to the repository's **Security** tab and click **"Report a vulnerability"**. This creates a private advisory visible only to maintainers and provides a structured workflow for triage, fix coordination, and CVE assignment.
|
||||
|
||||
2. **Email** — send the details to Nuno Maduro at **enunomaduro@gmail.com**.
|
||||
|
||||
All security vulnerabilities will be promptly addressed.
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -2,9 +2,17 @@ name: Static Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
concurrency:
|
||||
group: static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
static:
|
||||
@@ -12,33 +20,51 @@ jobs:
|
||||
name: Static Tests
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
dependency-version: [prefer-lowest, prefer-stable]
|
||||
dependency-version: [prefer-stable]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: 8.2
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
static-php-8.4-${{ matrix.dependency-version }}-composer-
|
||||
static-php-8.4-composer-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer update --prefer-stable --no-interaction --no-progress --ansi
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
|
||||
|
||||
# - name: Type Check
|
||||
# run: composer test:type:check
|
||||
- name: Profanity Check
|
||||
run: composer test:profanity
|
||||
|
||||
- name: Type Check
|
||||
run: composer test:type:check
|
||||
|
||||
- name: Type Coverage
|
||||
run: composer test:type:coverage
|
||||
|
||||
- name: Refacto
|
||||
run: composer test:refacto
|
||||
|
||||
- name: Style
|
||||
run: composer test:lint
|
||||
|
||||
@@ -2,33 +2,59 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [5.x]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
if: github.event_name != 'schedule' || github.repository == 'pestphp/pest'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
symfony: ['7.0']
|
||||
php: ['8.2', '8.3']
|
||||
dependency_version: [prefer-lowest, prefer-stable]
|
||||
os: [ubuntu-latest, macos-latest] # windows-latest
|
||||
symfony: ['8.0']
|
||||
php: ['8.4', '8.5']
|
||||
dependency_version: [prefer-stable]
|
||||
|
||||
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
extensions: sockets
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v5
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-composer-
|
||||
${{ matrix.os }}-php-${{ matrix.php }}-composer-
|
||||
|
||||
- name: Setup Problem Matches
|
||||
run: |
|
||||
@@ -36,7 +62,10 @@ jobs:
|
||||
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:~${{ matrix.symfony }}"
|
||||
shell: bash
|
||||
env:
|
||||
COMPOSER_ROOT_VERSION: 5.x-dev
|
||||
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
|
||||
|
||||
- name: Unit Tests
|
||||
run: composer test:unit
|
||||
|
||||
@@ -12,3 +12,5 @@ coverage.xml
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.STREAM.md
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# CLAUDE.md
|
||||
|
||||
**Do not edit this file.** Agents must never add, remove, or reword anything here. If a change seems needed, say so and let a human do it.
|
||||
|
||||
## Ask before testing
|
||||
|
||||
When asked to review code or build a feature, do not run the test suite and do not write new tests. Make the change, report it, then ask the user whether tests should be added — describing the tests you have in mind — and wait for the user to confirm.
|
||||
|
||||
Two reasons this matters here: the suite takes minutes, and `tests/.snapshots/success.txt` plus the tally in `tests/Visual/Parallel.php` encode the whole suite's result, so a single added test breaks both.
|
||||
|
||||
Once the user confirms:
|
||||
|
||||
```bash
|
||||
composer test:unit # fast, excludes the visual group
|
||||
composer test:integration # visual and snapshot tests
|
||||
composer test # everything CI runs, in CI's order
|
||||
composer update:snapshots # only when a test was added or removed
|
||||
```
|
||||
+1
-1
@@ -42,7 +42,7 @@ composer test
|
||||
|
||||
Check types:
|
||||
```bash
|
||||
composer test:types
|
||||
composer test:type:check
|
||||
```
|
||||
|
||||
Unit tests:
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Well documented Makefiles
|
||||
DEFAULT_GOAL := help
|
||||
help:
|
||||
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
|
||||
|
||||
build: ## Build all docker images. Specify the command e.g. via make build ARGS="--build-arg PHP=8.2"
|
||||
docker compose build $(ARGS)
|
||||
|
||||
##@ [Application]
|
||||
install: ## Install the composer dependencies
|
||||
docker compose run --rm composer install
|
||||
|
||||
test: ## Run the tests
|
||||
docker compose run --rm composer test
|
||||
@@ -1,43 +1,41 @@
|
||||
<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/v5/social.png" width="600" alt="PEST">
|
||||
<p align="center">
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (master)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=2.x&label=Tests%202.x"></a>
|
||||
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
|
||||
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
|
||||
<a href="https://whyphp.dev"><img src="https://img.shields.io/badge/Why_PHP-in_2026-7A86E8?style=flat-square&labelColor=18181b" alt="Why PHP in 2026"></a>
|
||||
<a href="https://youtube.com/@nunomaduro?sub_confirmation=1"><img alt="YouTube Channel Subscribers" src="https://img.shields.io/youtube/channel/subscribers/UCO_hYZF2gb_CyG5sA7ArlGg?style=flat&label=youtube&color=brightgreen"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
------
|
||||
|
||||
> Pest v3 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest3-now-available)**.
|
||||
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest5-now-available)**.
|
||||
|
||||
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.
|
||||
**Pest** is an elegant testing framework for PHP developers and AI agents.
|
||||
|
||||
- Explore our docs at **[pestphp.com »](https://pestphp.com)**
|
||||
- Follow us on Twitter at **[@pestphp »](https://twitter.com/pestphp)**
|
||||
- Join us at **[discord.gg/kaHY6p54JH »](https://discord.gg/kaHY6p54JH)** or **[t.me/+kYH5G4d5MV83ODk0 »](https://t.me/+kYH5G4d5MV83ODk0)**
|
||||
- Follow the creator Nuno Maduro:
|
||||
- YouTube: **[youtube.com/@nunomaduro](https://youtube.com/@nunomaduro)** — Videos every week
|
||||
- Twitch: **[twitch.tv/nunomaduro](https://twitch.tv/nunomaduro)** — Live coding on Mondays, Wednesdays, and Fridays at 9PM UTC
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
- **[LaraJobs](https://larajobs.com)**
|
||||
- **[Brokerchooser](https://brokerchooser.com)**
|
||||
- **[Forge](https://forge.laravel.com)**
|
||||
- **[Spatie](https://spatie.be)**
|
||||
- **[Worksome](https://www.worksome.com/)**
|
||||
|
||||
### Premium Sponsors
|
||||
|
||||
- [Akaunting](https://akaunting.com/?ref=pestphp)
|
||||
- [Codecourse](https://codecourse.com/?ref=pestphp)
|
||||
- [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp)
|
||||
- [Laracasts](https://laracasts.com/?ref=pestphp)
|
||||
- [Localazy](https://localazy.com/?ref=pestphp)
|
||||
- [Route4Me](https://www.route4me.com/?ref=pestphp)
|
||||
- [Zapiet](https://www.zapiet.com/?ref=pestphp)
|
||||
- **[CMS Max](https://cmsmax.com/?ref=pestphp)**
|
||||
- **[PhpStorm](https://jb.gg/nuno)**
|
||||
- **[CodeRabbit](https://coderabbit.link/nunomaduro)**
|
||||
- **[SerpApi](https://serpapi.com/?ref=nunomaduro)**
|
||||
- **[Typesense](https://typesense.org/?ref=nunomaduro)**
|
||||
- **[Bento](https://bentonow.com/?ref=nunomaduro)**
|
||||
- **[Pixel](https://wearepixel.com.au/?ref=nunomaduro)**
|
||||
- **[Redberry](https://redberry.international/laravel-development/?ref=nunomaduro)**
|
||||
|
||||
Pest is an open-sourced software licensed under the **[MIT license](https://opensource.org/licenses/MIT)**.
|
||||
|
||||
+3
-3
@@ -2,10 +2,10 @@
|
||||
|
||||
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 v4 you should use the `4.x` branch instead.**
|
||||
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 3.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)
|
||||
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
|
||||
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
|
||||
- Update the version number in [src/Pest.php](src/Pest.php)
|
||||
- Run the tests locally using: `composer test`
|
||||
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php declare(strict_types=1);
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Contracts\Restarter;
|
||||
use Pest\Kernel;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\NotesTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\PrTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\FlakyTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\TodoTestCaseFilter;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
@@ -21,6 +26,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
$dirty = false;
|
||||
$todo = false;
|
||||
$flaky = false;
|
||||
$notes = false;
|
||||
|
||||
foreach ($arguments as $key => $value) {
|
||||
@@ -37,7 +43,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
if (str_contains($value, '--test-directory=')) {
|
||||
unset($arguments[$key]);
|
||||
} else if ($value === '--test-directory') {
|
||||
} elseif ($value === '--test-directory') {
|
||||
unset($arguments[$key]);
|
||||
|
||||
if (isset($arguments[$key + 1])) {
|
||||
@@ -55,6 +61,11 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
unset($arguments[$key]);
|
||||
}
|
||||
|
||||
if ($value === '--flaky') {
|
||||
$flaky = true;
|
||||
unset($arguments[$key]);
|
||||
}
|
||||
|
||||
if ($value === '--notes') {
|
||||
$notes = true;
|
||||
unset($arguments[$key]);
|
||||
@@ -62,7 +73,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
if (str_contains($value, '--assignee=')) {
|
||||
unset($arguments[$key]);
|
||||
} else if ($value === '--assignee') {
|
||||
} elseif ($value === '--assignee') {
|
||||
unset($arguments[$key]);
|
||||
|
||||
if (isset($arguments[$key + 1])) {
|
||||
@@ -72,7 +83,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
if (str_contains($value, '--issue=')) {
|
||||
unset($arguments[$key]);
|
||||
} else if ($value === '--issue') {
|
||||
} elseif ($value === '--issue') {
|
||||
unset($arguments[$key]);
|
||||
|
||||
if (isset($arguments[$key + 1])) {
|
||||
@@ -82,7 +93,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
if (str_contains($value, '--ticket=')) {
|
||||
unset($arguments[$key]);
|
||||
} else if ($value === '--ticket') {
|
||||
} elseif ($value === '--ticket') {
|
||||
unset($arguments[$key]);
|
||||
|
||||
if (isset($arguments[$key + 1])) {
|
||||
@@ -92,7 +103,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
if (str_contains($value, '--pr=')) {
|
||||
unset($arguments[$key]);
|
||||
} else if ($value === '--pr') {
|
||||
} elseif ($value === '--pr') {
|
||||
unset($arguments[$key]);
|
||||
|
||||
if (isset($arguments[$key + 1])) {
|
||||
@@ -102,7 +113,7 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
if (str_contains($value, '--pull-request=')) {
|
||||
unset($arguments[$key]);
|
||||
} else if ($value === '--pull-request') {
|
||||
} elseif ($value === '--pull-request') {
|
||||
unset($arguments[$key]);
|
||||
|
||||
if (isset($arguments[$key + 1])) {
|
||||
@@ -117,7 +128,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Used when Pest is required using composer.
|
||||
$vendorPath = dirname(__DIR__, 4).'/vendor/autoload.php';
|
||||
|
||||
@@ -134,7 +144,8 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
// Get $rootPath based on $autoloadPath
|
||||
$rootPath = dirname($autoloadPath, 2);
|
||||
$input = new ArgvInput();
|
||||
|
||||
$input = new ArgvInput;
|
||||
|
||||
$testSuite = TestSuite::getInstance(
|
||||
$rootPath,
|
||||
@@ -146,11 +157,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
}
|
||||
|
||||
if ($todo) {
|
||||
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter());
|
||||
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter);
|
||||
}
|
||||
|
||||
if ($flaky) {
|
||||
$testSuite->tests->addTestCaseMethodFilter(new FlakyTestCaseFilter);
|
||||
}
|
||||
|
||||
if ($notes) {
|
||||
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter());
|
||||
$testSuite->tests->addTestCaseMethodFilter(new NotesTestCaseFilter);
|
||||
}
|
||||
|
||||
if ($assignee = $input->getParameterOption('--assignee')) {
|
||||
@@ -180,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
try {
|
||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||
|
||||
$container = Container::getInstance();
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
assert($restarter instanceof Restarter);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $originalArguments);
|
||||
}
|
||||
|
||||
$result = $kernel->handle($originalArguments, $arguments);
|
||||
|
||||
$kernel->terminate();
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve, relative, extname, sep, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const PAGE_EXTENSIONS = new Set([
|
||||
'.vue', '.svelte',
|
||||
'.tsx', '.jsx',
|
||||
'.ts', '.js',
|
||||
'.mts', '.cts', '.mjs', '.cjs',
|
||||
])
|
||||
const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i
|
||||
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
||||
const PAGE_DIR_CANDIDATES = [
|
||||
'resources/js/Pages',
|
||||
'resources/js/pages',
|
||||
'assets/js/Pages',
|
||||
'assets/js/pages',
|
||||
'assets/Pages',
|
||||
'assets/pages',
|
||||
]
|
||||
|
||||
async function loadRolldown() {
|
||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||
let path = null
|
||||
|
||||
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||
|
||||
if (path === null) {
|
||||
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||
// dependency of the vite package rather than a top-level install.
|
||||
try {
|
||||
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||
path = viteRequire.resolve('rolldown')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (path === null) return null
|
||||
|
||||
return await import(pathToFileURL(path).href)
|
||||
}
|
||||
|
||||
export function stripJsonComments(raw) {
|
||||
let out = ''
|
||||
let inString = false
|
||||
let quote = ''
|
||||
let inLine = false
|
||||
let inBlock = false
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const c = raw[i]
|
||||
const n = raw[i + 1]
|
||||
|
||||
if (inLine) {
|
||||
if (c === '\n') { inLine = false; out += c }
|
||||
continue
|
||||
}
|
||||
if (inBlock) {
|
||||
if (c === '*' && n === '/') { inBlock = false; i++ }
|
||||
continue
|
||||
}
|
||||
if (inString) {
|
||||
out += c
|
||||
if (c === '\\') { out += n ?? ''; i++; continue }
|
||||
if (c === quote) inString = false
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") { inString = true; quote = c; out += c; continue }
|
||||
if (c === '/' && n === '/') { inLine = true; i++; continue }
|
||||
if (c === '/' && n === '*') { inBlock = true; i++; continue }
|
||||
if (c === '}' || c === ']') out = out.replace(/,\s*$/, '')
|
||||
out += c
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
async function readJsonWithComments(path) {
|
||||
const raw = await readFile(path, 'utf8')
|
||||
return JSON.parse(stripJsonComments(raw))
|
||||
}
|
||||
|
||||
export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
let cfg
|
||||
try { cfg = await readJsonWithComments(p) } catch { continue }
|
||||
const baseUrl = resolve(projectRoot, cfg?.compilerOptions?.baseUrl ?? '.')
|
||||
const paths = cfg?.compilerOptions?.paths ?? {}
|
||||
for (const [key, targets] of Object.entries(paths)) {
|
||||
if (!key.endsWith('/*')) continue
|
||||
const t0 = Array.isArray(targets) ? targets[0] : null
|
||||
if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue
|
||||
const aliasKey = key.slice(0, -2)
|
||||
if (alias[aliasKey] !== undefined) continue
|
||||
alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2))
|
||||
}
|
||||
}
|
||||
return alias
|
||||
}
|
||||
|
||||
const VITE_CONFIG_FILES = [
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
'vite.config.cts',
|
||||
]
|
||||
|
||||
function resolveAliasTarget(projectRoot, target) {
|
||||
if (target.startsWith('/')) {
|
||||
// Vite resolves a leading slash against the project root, unless the
|
||||
// config author used a genuinely absolute path.
|
||||
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
||||
}
|
||||
|
||||
return resolve(projectRoot, target)
|
||||
}
|
||||
|
||||
async function usesLaravelVitePlugin(projectRoot) {
|
||||
const p = join(projectRoot, 'package.json')
|
||||
if (!existsSync(p)) return false
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
||||
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
||||
const alias = {}
|
||||
let source = null
|
||||
|
||||
for (const name of VITE_CONFIG_FILES) {
|
||||
const p = join(projectRoot, name)
|
||||
if (!existsSync(p)) continue
|
||||
try { source = await readFile(p, 'utf8') } catch { continue }
|
||||
break
|
||||
}
|
||||
|
||||
if (source !== null) {
|
||||
// The config is executable code we cannot import safely, so extract alias
|
||||
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
||||
// the value expression — covers `'@': '/resources/js'` as well as
|
||||
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
||||
// top-level comma; call arguments are kept via the paren group.
|
||||
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
||||
const key = m[1]
|
||||
if (alias[key] !== undefined) continue
|
||||
|
||||
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
||||
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
||||
if (!target || target.includes('*')) continue
|
||||
|
||||
alias[key] = resolveAliasTarget(projectRoot, target)
|
||||
}
|
||||
}
|
||||
|
||||
// laravel-vite-plugin registers '@' → resources/js by default.
|
||||
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
||||
alias['@'] = resolve(projectRoot, 'resources/js')
|
||||
}
|
||||
|
||||
return alias
|
||||
}
|
||||
|
||||
async function listPageFiles(pagesDir) {
|
||||
if (!existsSync(pagesDir)) return []
|
||||
|
||||
const out = []
|
||||
const walk = async (dir) => {
|
||||
let entries
|
||||
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
||||
for (const entry of entries) {
|
||||
const full = resolve(dir, entry.name)
|
||||
if (entry.isDirectory()) { await walk(full); continue }
|
||||
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
|
||||
}
|
||||
}
|
||||
|
||||
await walk(pagesDir)
|
||||
return out
|
||||
}
|
||||
|
||||
async function discoverPagesDir() {
|
||||
const override = process.env.TIA_VITE_PAGES_DIR
|
||||
if (override && override.length > 0) {
|
||||
return resolve(PROJECT_ROOT, override.replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
for (const rel of PAGE_DIR_CANDIDATES) {
|
||||
const abs = resolve(PROJECT_ROOT, rel)
|
||||
if (!existsSync(abs)) continue
|
||||
const files = await listPageFiles(abs)
|
||||
if (files.length > 0) return abs
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function componentNameFor(pageAbs, pagesDir) {
|
||||
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
||||
const ext = extname(rel)
|
||||
return rel.slice(0, rel.length - ext.length)
|
||||
}
|
||||
|
||||
function isLocalSpecifier(source, aliasKeys) {
|
||||
if (source.startsWith('.') || source.startsWith('/')) return true
|
||||
for (const key of aliasKeys) {
|
||||
if (source === key || source.startsWith(key + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pagesDir = await discoverPagesDir()
|
||||
|
||||
if (pagesDir === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const pages = await listPageFiles(pagesDir)
|
||||
|
||||
if (pages.length === 0) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = await loadRolldown()
|
||||
|
||||
if (loaded === null) {
|
||||
process.stdout.write('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const { rolldown } = loaded
|
||||
// The vite config is what the dev server actually resolves with — let it win.
|
||||
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
||||
const aliasKeys = Object.keys(alias)
|
||||
|
||||
const graph = new Map()
|
||||
|
||||
const collector = {
|
||||
name: 'pest-tia-collector',
|
||||
moduleParsed(info) {
|
||||
const id = info.id
|
||||
if (!id || id.startsWith('\0')) return
|
||||
const deps = new Set()
|
||||
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
graph.set(id, deps)
|
||||
},
|
||||
}
|
||||
|
||||
const externalBare = {
|
||||
name: 'pest-tia-external-bare',
|
||||
resolveId(source) {
|
||||
if (!source) return null
|
||||
if (isLocalSpecifier(source, aliasKeys)) return null
|
||||
return { id: source, external: true }
|
||||
},
|
||||
}
|
||||
|
||||
const assetStub = {
|
||||
name: 'pest-tia-asset-stub',
|
||||
load(id) {
|
||||
if (!id) return null
|
||||
if (ASSET_EXT_RE.test(id)) {
|
||||
return { code: 'export default null', moduleSideEffects: false }
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const input = Object.create(null)
|
||||
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
||||
|
||||
const bundle = await rolldown({
|
||||
input,
|
||||
cwd: PROJECT_ROOT,
|
||||
resolve: {
|
||||
alias,
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||
},
|
||||
transform: { jsx: 'preserve' },
|
||||
treeshake: false,
|
||||
plugins: [externalBare, assetStub, collector],
|
||||
logLevel: 'silent',
|
||||
onLog: () => {},
|
||||
})
|
||||
|
||||
try {
|
||||
await bundle.generate({ format: 'esm' })
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
|
||||
const reverse = new Map()
|
||||
const transitiveCache = new Map()
|
||||
|
||||
const computeTransitive = (id, stack) => {
|
||||
const cached = transitiveCache.get(id)
|
||||
if (cached) return cached
|
||||
if (stack.has(id)) return null
|
||||
|
||||
stack.add(id)
|
||||
const acc = new Set()
|
||||
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
||||
// that dependency's subtree and must not be memoized — the ancestor call
|
||||
// completes it for the current traversal, but a cached copy would leak the
|
||||
// incomplete set into other pages' traversals.
|
||||
let complete = true
|
||||
const deps = graph.get(id)
|
||||
if (deps) {
|
||||
for (const dep of deps) {
|
||||
if (!dep || dep.startsWith('\0')) continue
|
||||
if (dep.startsWith(PROJECT_ROOT)) {
|
||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||
acc.add(rel)
|
||||
}
|
||||
if (stack.has(dep)) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
const child = computeTransitive(dep, stack)
|
||||
if (child) for (const r of child) acc.add(r)
|
||||
if (!transitiveCache.has(dep)) complete = false
|
||||
}
|
||||
}
|
||||
stack.delete(id)
|
||||
if (complete) transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const pageComponent = componentNameFor(page, pagesDir)
|
||||
const reachable = computeTransitive(page, new Set())
|
||||
if (!reachable) continue
|
||||
for (const rel of reachable) {
|
||||
const bucket = reverse.get(rel) ?? new Set()
|
||||
bucket.add(pageComponent)
|
||||
reverse.set(rel, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = Object.create(null)
|
||||
const keys = [...reverse.keys()].sort()
|
||||
for (const key of keys) {
|
||||
payload[key] = [...reverse.get(key)].sort()
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(payload))
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] !== undefined
|
||||
&& import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
await main()
|
||||
} catch (err) {
|
||||
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
|
||||
use ParaTest\WrapperRunner\WrapperWorker;
|
||||
use Pest\Kernel;
|
||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
@@ -32,10 +33,13 @@ $bootPest = (static function (): void {
|
||||
'status-file:',
|
||||
'progress-file:',
|
||||
'unexpected-output-file:',
|
||||
'testresult-file:',
|
||||
'test-result-file:',
|
||||
'result-cache-file:',
|
||||
'teamcity-file:',
|
||||
'testdox-file:',
|
||||
'testdox-color',
|
||||
'testdox-columns:',
|
||||
'testdox-summary',
|
||||
'phpunit-argv:',
|
||||
]);
|
||||
|
||||
@@ -55,13 +59,23 @@ $bootPest = (static function (): void {
|
||||
}
|
||||
}
|
||||
|
||||
$container = Container::getInstance();
|
||||
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
|
||||
}
|
||||
|
||||
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
|
||||
$statusFile = fopen($getopt['status-file'], 'wb');
|
||||
assert(is_resource($statusFile));
|
||||
|
||||
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['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['testdox-file']) || is_string($getopt['testdox-file']));
|
||||
|
||||
@@ -77,11 +91,12 @@ $bootPest = (static function (): void {
|
||||
$phpunitArgv,
|
||||
$getopt['progress-file'],
|
||||
$getopt['unexpected-output-file'],
|
||||
$getopt['testresult-file'],
|
||||
$getopt['test-result-file'],
|
||||
$getopt['result-cache-file'] ?? null,
|
||||
$getopt['teamcity-file'] ?? null,
|
||||
$getopt['testdox-file'] ?? null,
|
||||
isset($getopt['testdox-color']),
|
||||
$getopt['testdox-columns'] ?? null,
|
||||
(int) ($getopt['testdox-columns'] ?? null),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
|
||||
+38
-23
@@ -17,18 +17,21 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.2.0",
|
||||
"brianium/paratest": "^7.5.4",
|
||||
"nunomaduro/collision": "^8.4.0",
|
||||
"nunomaduro/termwind": "^2.1.0",
|
||||
"pestphp/pest-plugin": "^3.0.0",
|
||||
"pestphp/pest-plugin-arch": "^3.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^3.0.3",
|
||||
"phpunit/phpunit": "^11.3.6"
|
||||
"php": "^8.4",
|
||||
"brianium/paratest": "^7.23.1",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^5.0.0",
|
||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||
"phpunit/phpunit": "^13.2.6",
|
||||
"symfony/process": "^8.1.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": ">11.3.6",
|
||||
"sebastian/exporter": "<6.0.0",
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">13.2.6",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -47,14 +50,20 @@
|
||||
"Tests\\Fixtures\\Arch\\": "tests/Fixtures/Arch",
|
||||
"Tests\\": "tests/PHPUnit/"
|
||||
},
|
||||
"classmap": [
|
||||
"tests/Fixtures/Arch/ToBeCasedCorrectly/IncorrectCasing/incorrectCasing.php"
|
||||
],
|
||||
"files": [
|
||||
"tests/Autoload.php"
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest-dev-tools": "^3.0.0",
|
||||
"pestphp/pest-plugin-type-coverage": "^3.0.0",
|
||||
"symfony/process": "^7.1.3"
|
||||
"pestphp/pest-dev-tools": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||
"pestphp/pest-plugin-phpstan": "^5.0.0",
|
||||
"pestphp/pest-plugin-rector": "^5.0.2",
|
||||
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||
"psy/psysh": "^0.12.24"
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
@@ -69,19 +78,23 @@
|
||||
"bin/pest"
|
||||
],
|
||||
"scripts": {
|
||||
"refacto": "rector",
|
||||
"lint": "pint",
|
||||
"test:refacto": "rector --dry-run",
|
||||
"test:lint": "pint --test",
|
||||
"lint": [
|
||||
"rector",
|
||||
"pint --parallel"
|
||||
],
|
||||
"test:lint": [
|
||||
"rector --dry-run",
|
||||
"pint --parallel --test"
|
||||
],
|
||||
"test:profanity": "php bin/pest --profanity --compact",
|
||||
"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:unit": "php bin/pest --colors=always --exclude-group=integration --compact",
|
||||
"test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --colors=always --group=integration -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots",
|
||||
"test:unit": "php bin/pest --exclude-group=integration --compact",
|
||||
"test:inline": "php bin/pest --configuration=phpunit.inline.xml",
|
||||
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
|
||||
"test:integration": "php bin/pest --group=integration -v",
|
||||
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
|
||||
"test": [
|
||||
"@test:refacto",
|
||||
"@test:lint",
|
||||
"@test:type:check",
|
||||
"@test:type:coverage",
|
||||
@@ -110,6 +123,8 @@
|
||||
"Pest\\Plugins\\Snapshot",
|
||||
"Pest\\Plugins\\Verbose",
|
||||
"Pest\\Plugins\\Version",
|
||||
"Pest\\Plugins\\Shard",
|
||||
"Pest\\Plugins\\Tia",
|
||||
"Pest\\Plugins\\Parallel"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: ./docker
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
composer:
|
||||
build:
|
||||
context: ./docker
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
entrypoint: ["composer"]
|
||||
@@ -52,6 +52,8 @@ use PHPUnit\Util\Filter;
|
||||
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
|
||||
*/
|
||||
final readonly class ThrowableBuilder
|
||||
@@ -68,7 +70,7 @@ final readonly class ThrowableBuilder
|
||||
$previous = self::from($previous);
|
||||
}
|
||||
|
||||
$trace = Filter::getFilteredStacktrace($t);
|
||||
$trace = Filter::stackTraceFromThrowableAsString($t);
|
||||
|
||||
if ($t instanceof RenderableOnCollisionEditor && $frame = $t->toCollisionEditor()) {
|
||||
$file = $frame->getFile();
|
||||
@@ -82,7 +84,7 @@ final readonly class ThrowableBuilder
|
||||
$t->getMessage(),
|
||||
ThrowableToStringMapper::map($t),
|
||||
$trace,
|
||||
$previous
|
||||
$previous,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of PHPUnit.
|
||||
*
|
||||
@@ -14,6 +47,9 @@ namespace PHPUnit\Logging\JUnit;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use Pest\Logging\Converter;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\EventFacadeIsSealedException;
|
||||
@@ -27,6 +63,7 @@ use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\PreparationStarted;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\TestSuite\Started;
|
||||
use PHPUnit\Event\UnknownSubscriberTypeException;
|
||||
@@ -41,13 +78,15 @@ use function str_replace;
|
||||
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
|
||||
*/
|
||||
final class JunitXmlLogger
|
||||
{
|
||||
private readonly Printer $printer;
|
||||
|
||||
private readonly \Pest\Logging\Converter $converter; // pest-added
|
||||
private readonly Converter $converter; // pest-added
|
||||
|
||||
private DOMDocument $document;
|
||||
|
||||
@@ -59,32 +98,32 @@ final class JunitXmlLogger
|
||||
private array $testSuites = [];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteTests = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteAssertions = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteErrors = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteFailures = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteSkipped = [0];
|
||||
|
||||
/**
|
||||
* @psalm-var array<int,int>
|
||||
* @var array<int,int>
|
||||
*/
|
||||
private array $testSuiteTimes = [0];
|
||||
|
||||
@@ -105,7 +144,7 @@ final class JunitXmlLogger
|
||||
public function __construct(Printer $printer, Facade $facade)
|
||||
{
|
||||
$this->printer = $printer;
|
||||
$this->converter = new \Pest\Logging\Converter(\Pest\Support\Container::getInstance()->get(\Pest\TestSuite::class)->rootPath); // pest-added
|
||||
$this->converter = new Converter(Container::getInstance()->get(TestSuite::class)->rootPath); // pest-added
|
||||
|
||||
$this->registerSubscribers($facade);
|
||||
$this->createDocument();
|
||||
@@ -113,7 +152,7 @@ final class JunitXmlLogger
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->printer->print($this->document->saveXML());
|
||||
$this->printer->print($this->document->saveXML() ?: '');
|
||||
|
||||
$this->printer->flush();
|
||||
}
|
||||
@@ -195,28 +234,34 @@ final class JunitXmlLogger
|
||||
$this->createTestCase($event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function testPreparationFailed(): void
|
||||
{
|
||||
$this->preparationFailed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function testPrepared(): void
|
||||
{
|
||||
$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
|
||||
*/
|
||||
public function testFinished(Finished $event): void
|
||||
{
|
||||
if ($this->preparationFailed) {
|
||||
if (! $this->prepared || $this->preparationFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -305,9 +350,11 @@ final class JunitXmlLogger
|
||||
new TestPreparationStartedSubscriber($this),
|
||||
new TestPreparationFailedSubscriber($this),
|
||||
new TestPreparedSubscriber($this),
|
||||
new TestPrintedUnexpectedOutputSubscriber($this),
|
||||
new TestFinishedSubscriber($this),
|
||||
new TestErroredSubscriber($this),
|
||||
new TestFailedSubscriber($this),
|
||||
new TestMarkedIncompleteSubscriber($this),
|
||||
new TestSkippedSubscriber($this),
|
||||
new TestRunnerExecutionFinishedSubscriber($this),
|
||||
);
|
||||
@@ -431,7 +478,7 @@ final class JunitXmlLogger
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*
|
||||
* @psalm-assert !null $this->currentTestCase
|
||||
* @phpstan-assert !null $this->currentTestCase
|
||||
*/
|
||||
private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void
|
||||
{
|
||||
@@ -446,7 +493,7 @@ final class JunitXmlLogger
|
||||
if ($test->isTestMethod()) {
|
||||
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
|
||||
$testCase->setAttribute('class', $className); // pest-changed
|
||||
$testCase->setAttribute('classname', str_replace('\\', '.', $className)); // pest-changed
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2011 Brian Scaturro
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ParaTest\WrapperRunner;
|
||||
|
||||
use PHPUnit\TextUI\Output\Printer;
|
||||
|
||||
use function preg_match;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* This file is overridden so the "T" progress character — emitted by Pest for
|
||||
* "todo" tests — is routed to the progress file next to the regular characters,
|
||||
* instead of being treated as unexpected output.
|
||||
*/
|
||||
final readonly class ProgressPrinterOutput implements Printer
|
||||
{
|
||||
public function __construct(
|
||||
private Printer $progressPrinter,
|
||||
private Printer $outputPrinter,
|
||||
) {}
|
||||
|
||||
public function print(string $buffer): void
|
||||
{
|
||||
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
|
||||
if (
|
||||
$buffer === "\n"
|
||||
|| preg_match('/^ +$/', $buffer) === 1
|
||||
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($buffer) {
|
||||
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
|
||||
default => $this->outputPrinter->print($buffer),
|
||||
};
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
{
|
||||
$this->progressPrinter->flush();
|
||||
$this->outputPrinter->flush();
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,9 @@ abstract class NameFilterIterator extends RecursiveFilterIterator
|
||||
}
|
||||
|
||||
if ($test instanceof HasPrintableTestCaseName) {
|
||||
$name = $test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName();
|
||||
$name = trim(
|
||||
$test::getPrintableTestCaseName().'::'.$test->getPrintableTestCaseMethodName().$test->dataSetAsString()
|
||||
);
|
||||
} else {
|
||||
$name = $test::class.'::'.$test->nameWithDataSet();
|
||||
}
|
||||
|
||||
@@ -46,9 +46,10 @@ declare(strict_types=1);
|
||||
namespace PHPUnit\Runner\ResultCache;
|
||||
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
use const LOCK_EX;
|
||||
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use PHPUnit\Runner\DirectoryCannotBeCreatedException;
|
||||
use PHPUnit\Runner\DirectoryDoesNotExistException;
|
||||
use PHPUnit\Runner\Exception;
|
||||
use PHPUnit\Util\Filesystem;
|
||||
|
||||
@@ -65,24 +66,23 @@ use function json_encode;
|
||||
use function Pest\version;
|
||||
|
||||
/**
|
||||
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*/
|
||||
final class DefaultResultCache implements ResultCache
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
|
||||
private const string DEFAULT_RESULT_CACHE_FILENAME = '.phpunit.result.cache';
|
||||
|
||||
private readonly string $cacheFilename;
|
||||
|
||||
/**
|
||||
* @psalm-var array<string, TestStatus>
|
||||
* @var array<string, TestStatus>
|
||||
*/
|
||||
private array $defects = [];
|
||||
|
||||
/**
|
||||
* @psalm-var array<string, float>
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private array $times = [];
|
||||
|
||||
@@ -95,28 +95,39 @@ final class DefaultResultCache implements ResultCache
|
||||
$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()) {
|
||||
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
|
||||
@@ -165,7 +176,7 @@ final class DefaultResultCache implements ResultCache
|
||||
public function persist(): void
|
||||
{
|
||||
if (! Filesystem::createDirectory(dirname($this->cacheFilename))) {
|
||||
throw new DirectoryCannotBeCreatedException($this->cacheFilename);
|
||||
throw new DirectoryDoesNotExistException(dirname($this->cacheFilename));
|
||||
}
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -90,11 +90,11 @@ final class TestSuiteLoader
|
||||
(static function () use ($suiteClassFile) {
|
||||
try {
|
||||
include_once $suiteClassFile;
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
} catch (Throwable $e) {
|
||||
Panic::with($e);
|
||||
}
|
||||
|
||||
TestSuite::getInstance()->tests->makeIfNeeded($suiteClassFile);
|
||||
})();
|
||||
|
||||
$loadedClasses = array_values(
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* BSD 3-Clause License
|
||||
*
|
||||
* Copyright (c) 2001-2023, Sebastian Bergmann
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* 3. Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of PHPUnit.
|
||||
*
|
||||
* (c) Sebastian Bergmann <sebastian@phpunit.de>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace PHPUnit\Runner;
|
||||
|
||||
use PHPUnit\Framework\DataProviderTestSuite;
|
||||
use PHPUnit\Framework\Reorderable;
|
||||
use PHPUnit\Framework\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestSuite;
|
||||
use PHPUnit\Runner\ResultCache\NullResultCache;
|
||||
use PHPUnit\Runner\ResultCache\ResultCache;
|
||||
use PHPUnit\Runner\ResultCache\ResultCacheId;
|
||||
|
||||
use function array_diff;
|
||||
use function array_merge;
|
||||
use function array_reverse;
|
||||
use function array_splice;
|
||||
use function assert;
|
||||
use function count;
|
||||
use function in_array;
|
||||
use function max;
|
||||
use function shuffle;
|
||||
use function usort;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*/
|
||||
final class TestSuiteSorter
|
||||
{
|
||||
public const int ORDER_DEFAULT = 0;
|
||||
|
||||
public const int ORDER_RANDOMIZED = 1;
|
||||
|
||||
public const int ORDER_REVERSED = 2;
|
||||
|
||||
public const int ORDER_DEFECTS_FIRST = 3;
|
||||
|
||||
public const int ORDER_DURATION = 4;
|
||||
|
||||
public const int ORDER_SIZE = 5;
|
||||
|
||||
/**
|
||||
* @var non-empty-array<non-empty-string, positive-int>
|
||||
*/
|
||||
private const array SIZE_SORT_WEIGHT = [
|
||||
'small' => 1,
|
||||
'medium' => 2,
|
||||
'large' => 3,
|
||||
'unknown' => 4,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, int> Associative array of (string => DEFECT_SORT_WEIGHT) elements
|
||||
*/
|
||||
private array $defectSortOrder = [];
|
||||
|
||||
private readonly ResultCache $cache;
|
||||
|
||||
public function __construct(?ResultCache $cache = null)
|
||||
{
|
||||
$this->cache = $cache ?? new NullResultCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void
|
||||
{
|
||||
$allowedOrders = [
|
||||
self::ORDER_DEFAULT,
|
||||
self::ORDER_REVERSED,
|
||||
self::ORDER_RANDOMIZED,
|
||||
self::ORDER_DURATION,
|
||||
self::ORDER_SIZE,
|
||||
];
|
||||
|
||||
if (! in_array($order, $allowedOrders, true)) {
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new InvalidOrderException;
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
$allowedOrderDefects = [
|
||||
self::ORDER_DEFAULT,
|
||||
self::ORDER_DEFECTS_FIRST,
|
||||
];
|
||||
|
||||
if (! in_array($orderDefects, $allowedOrderDefects, true)) {
|
||||
// @codeCoverageIgnoreStart
|
||||
throw new InvalidOrderException;
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
if ($suite instanceof TestSuite) {
|
||||
foreach ($suite as $_suite) {
|
||||
$this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects);
|
||||
}
|
||||
|
||||
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
|
||||
$this->addSuiteToDefectSortOrder($suite);
|
||||
}
|
||||
|
||||
$this->sort($suite, $order, $resolveDependencies, $orderDefects);
|
||||
}
|
||||
}
|
||||
|
||||
private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void
|
||||
{
|
||||
if ($suite->tests() === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($order === self::ORDER_REVERSED) {
|
||||
$suite->setTests($this->reverse($suite->tests()));
|
||||
} elseif ($order === self::ORDER_RANDOMIZED) {
|
||||
$suite->setTests($this->randomize($suite->tests()));
|
||||
} elseif ($order === self::ORDER_DURATION) {
|
||||
$suite->setTests($this->sortByDuration($suite->tests()));
|
||||
} elseif ($order === self::ORDER_SIZE) {
|
||||
$suite->setTests($this->sortBySize($suite->tests()));
|
||||
}
|
||||
|
||||
if ($orderDefects === self::ORDER_DEFECTS_FIRST) {
|
||||
$suite->setTests($this->sortDefectsFirst($suite->tests()));
|
||||
}
|
||||
|
||||
if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) {
|
||||
$tests = $suite->tests();
|
||||
|
||||
/** @noinspection PhpParamsInspection */
|
||||
/** @phpstan-ignore argument.type */
|
||||
$suite->setTests($this->resolveDependencies($tests));
|
||||
}
|
||||
}
|
||||
|
||||
private function addSuiteToDefectSortOrder(TestSuite $suite): void
|
||||
{
|
||||
$max = 0;
|
||||
|
||||
foreach ($suite->tests() as $test) {
|
||||
assert($test instanceof Reorderable);
|
||||
|
||||
$sortId = $test->sortId();
|
||||
|
||||
if (! isset($this->defectSortOrder[$sortId])) {
|
||||
$this->defectSortOrder[$sortId] = $this->cache->status(ResultCacheId::fromReorderable($test))->asInt();
|
||||
$max = max($max, $this->defectSortOrder[$sortId]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->defectSortOrder[$suite->sortId()] = $max;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function reverse(array $tests): array
|
||||
{
|
||||
return array_reverse($tests);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function randomize(array $tests): array
|
||||
{
|
||||
shuffle($tests);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function sortDefectsFirst(array $tests): array
|
||||
{
|
||||
usort(
|
||||
$tests,
|
||||
fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right),
|
||||
);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function sortByDuration(array $tests): array
|
||||
{
|
||||
usort(
|
||||
$tests,
|
||||
fn (Test $left, Test $right) => $this->cmpDuration($left, $right),
|
||||
);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Test> $tests
|
||||
* @return list<Test>
|
||||
*/
|
||||
private function sortBySize(array $tests): array
|
||||
{
|
||||
usort(
|
||||
$tests,
|
||||
fn (Test $left, Test $right) => $this->cmpSize($left, $right),
|
||||
);
|
||||
|
||||
return $tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator callback function to sort tests for "reach failure as fast as possible".
|
||||
*
|
||||
* 1. sort tests by defect weight defined in self::DEFECT_SORT_WEIGHT
|
||||
* 2. when tests are equally defective, sort the fastest to the front
|
||||
* 3. do not reorder successful tests
|
||||
*/
|
||||
private function cmpDefectPriorityAndTime(Test $a, Test $b): int
|
||||
{
|
||||
assert($a instanceof Reorderable);
|
||||
assert($b instanceof Reorderable);
|
||||
|
||||
$priorityA = $this->defectSortOrder[$a->sortId()] ?? 0;
|
||||
$priorityB = $this->defectSortOrder[$b->sortId()] ?? 0;
|
||||
|
||||
if ($priorityA !== $priorityB) {
|
||||
// Sort defect weight descending
|
||||
return $priorityB <=> $priorityA;
|
||||
}
|
||||
|
||||
if ($priorityA > 0 || $priorityB > 0) {
|
||||
return $this->cmpDuration($a, $b);
|
||||
}
|
||||
|
||||
// do not change execution order
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares test duration for sorting tests by duration ascending.
|
||||
*/
|
||||
private function cmpDuration(Test $a, Test $b): int
|
||||
{
|
||||
if (! ($a instanceof Reorderable && $b instanceof Reorderable)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares test size for sorting tests small->medium->large->unknown.
|
||||
*/
|
||||
private function cmpSize(Test $a, Test $b): int
|
||||
{
|
||||
$sizeA = ($a instanceof TestCase || $a instanceof DataProviderTestSuite)
|
||||
? $a->size()->asString()
|
||||
: 'unknown';
|
||||
$sizeB = ($b instanceof TestCase || $b instanceof DataProviderTestSuite)
|
||||
? $b->size()->asString()
|
||||
: 'unknown';
|
||||
|
||||
return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible.
|
||||
* The algorithm will leave the tests in original running order when it can.
|
||||
* For more details see the documentation for test dependencies.
|
||||
*
|
||||
* Short description of algorithm:
|
||||
* 1. Pick the next Test from remaining tests to be checked for dependencies.
|
||||
* 2. If the test has no dependencies: mark done, start again from the top
|
||||
* 3. If the test has dependencies but none left to do: mark done, start again from the top
|
||||
* 4. When we reach the end add any leftover tests to the end. These will be marked 'skipped' during execution.
|
||||
*
|
||||
* @param array<TestCase> $tests
|
||||
* @return array<TestCase>
|
||||
*/
|
||||
private function resolveDependencies(array $tests): array
|
||||
{
|
||||
// Pest: Fast-path. If no test in this suite declares dependencies, the
|
||||
// original O(N^2) algorithm is wasted work — it would splice each test
|
||||
// one-by-one back into the same order. The check deliberately walks
|
||||
// TestCase instances directly instead of calling TestSuite::requires(),
|
||||
// because the latter lazily builds TestSuite::provides() via
|
||||
// ExecutionOrderDependency::mergeUnique, which is O(N^2) in the total
|
||||
// number of tests. With thousands of tests that single call alone can
|
||||
// burn several seconds before the sort even begins. Reading the
|
||||
// cached TestCase::$dependencies property stays O(N) and costs nothing
|
||||
// when no test uses `->depends()` / PHPUnit `@depends`.
|
||||
if (! $this->anyTestHasDependencies($tests)) {
|
||||
return $tests;
|
||||
}
|
||||
|
||||
$newTestOrder = [];
|
||||
$i = 0;
|
||||
$provided = [];
|
||||
|
||||
do {
|
||||
if (array_diff($tests[$i]->requires(), $provided) === []) {
|
||||
$provided = array_merge($provided, $tests[$i]->provides());
|
||||
$newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1));
|
||||
$i = 0;
|
||||
} else {
|
||||
$i++;
|
||||
}
|
||||
} while ($tests !== [] && ($i < count($tests)));
|
||||
|
||||
return array_merge($newTestOrder, $tests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheaply determines whether any test in the tree declares @depends.
|
||||
*
|
||||
* Walks `TestSuite` containers recursively and inspects each `TestCase`
|
||||
* directly so it never triggers `TestSuite::provides()`, which is O(N^2)
|
||||
* in the total number of aggregated tests.
|
||||
*
|
||||
* @param iterable<Test> $tests
|
||||
*/
|
||||
private function anyTestHasDependencies(iterable $tests): bool
|
||||
{
|
||||
foreach ($tests as $test) {
|
||||
if ($test instanceof TestSuite) {
|
||||
if ($this->anyTestHasDependencies($test->tests())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($test instanceof TestCase && $test->requires() !== []) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -43,7 +43,7 @@ declare(strict_types=1);
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
|
||||
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
@@ -51,16 +51,20 @@ use ReflectionClass;
|
||||
|
||||
/**
|
||||
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
||||
*
|
||||
* This file is overridden so PHPUnit's progress output emits a "T" before the
|
||||
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
|
||||
* "T" and swallows the "S" that follows it.
|
||||
*/
|
||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if (str_contains($event->message(), '__TODO__')) {
|
||||
if ($event->message() === '__TODO__') {
|
||||
$this->printTodoItem();
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
$this->printer()->testSkipped();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
private function printTodoItem(): void
|
||||
{
|
||||
$mirror = new ReflectionClass($this->printer());
|
||||
$printerMirror = $mirror->getMethod('printProgress');
|
||||
$printerMirror->invoke($this->printer(), 'T');
|
||||
$printProgress = $mirror->getMethod('printProgress');
|
||||
$printProgress->invoke($this->printer(), 'T');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ declare(strict_types=1);
|
||||
namespace PHPUnit\TextUI;
|
||||
|
||||
use Pest\Plugins\Only;
|
||||
use Pest\Runner\Filter\EnsureTestCaseIsInitiatedFilter;
|
||||
use PHPUnit\Event;
|
||||
use PHPUnit\Framework\TestSuite;
|
||||
use PHPUnit\Runner\Filter\Factory;
|
||||
@@ -66,6 +67,12 @@ final readonly class TestSuiteFilterProcessor
|
||||
{
|
||||
$factory = new Factory;
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
(fn () => $this->filters[] = [
|
||||
'className' => EnsureTestCaseIsInitiatedFilter::class,
|
||||
'argument' => '',
|
||||
])->call($factory);
|
||||
|
||||
if (! $configuration->hasFilter() &&
|
||||
! $configuration->hasGroups() &&
|
||||
! $configuration->hasExcludeGroups() &&
|
||||
@@ -73,6 +80,8 @@ final readonly class TestSuiteFilterProcessor
|
||||
! $configuration->hasTestsCovering() &&
|
||||
! $configuration->hasTestsUsing() &&
|
||||
! Only::isEnabled()) {
|
||||
$suite->injectFilter($factory);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
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: '#^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 \#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 \#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
|
||||
@@ -0,0 +1,5 @@
|
||||
services:
|
||||
-
|
||||
class: Pest\PHPStan\HigherOrderExpectationTypeExtension
|
||||
tags:
|
||||
- phpstan.broker.expressionTypeResolverExtension
|
||||
+5
-17
@@ -1,23 +1,11 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-strict-rules/rules.neon
|
||||
- vendor/ergebnis/phpstan-rules/rules.neon
|
||||
- vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
|
||||
- phpstan-baseline.neon
|
||||
- phpstan-pest-extension.neon
|
||||
- vendor/pestphp/pest-plugin-phpstan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: max
|
||||
level: 7
|
||||
paths:
|
||||
- src
|
||||
|
||||
checkMissingIterableValueType: true
|
||||
reportUnmatchedIgnoredErrors: true
|
||||
|
||||
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#"
|
||||
- "# with null as default value#"
|
||||
- "#has parameter \\$closure with default value.#"
|
||||
- "#has parameter \\$description with default value.#"
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
|
||||
+2
-1
@@ -16,9 +16,10 @@
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory suffix=".php">./tests</directory>
|
||||
<directory suffix=".php">./tests-external</directory>
|
||||
<exclude>./tests/.snapshots</exclude>
|
||||
<exclude>./tests/.tests</exclude>
|
||||
<exclude>./tests/Fixtures/Inheritance</exclude>
|
||||
<exclude>./tests/Fixtures/Suites</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
|
||||
+47
@@ -2,16 +2,63 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Rector\Rules\UseToMatchArrayRector;
|
||||
use Pest\Rector\Set\PestSetList;
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingConstructorRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector;
|
||||
use Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector;
|
||||
use Rector\DeadCode\Rector\Property\RemoveUnusedPrivatePropertyRector;
|
||||
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\NarrowObjectReturnTypeRector;
|
||||
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withSets([
|
||||
PestSetList::CODING_STYLE,
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__.'/src/Plugins/Parallel/Paratest/WrapperRunner.php',
|
||||
__DIR__.'/tests/Fixtures/Arch',
|
||||
// Fixture suites are pinned by the TeamCity / JUnit snapshots, down to
|
||||
// the line numbers — rewriting their source would break them.
|
||||
__DIR__.'/tests/Fixtures/Suites',
|
||||
ReturnNeverTypeRector::class,
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class,
|
||||
NarrowObjectReturnTypeRector::class,
|
||||
RemoveParentDelegatingConstructorRector::class,
|
||||
RemoveDuplicatedReturnSelfDocblockRector::class,
|
||||
RemoveUselessUnionReturnDocblockRector::class,
|
||||
RemoveReturnTagIncompatibleWithNativeTypeRector::class => [
|
||||
__DIR__.'/src/Expectations/HigherOrderExpectation.php',
|
||||
],
|
||||
// Merges unrelated expectations into a single `toMatchArray()`, turning
|
||||
// `toContain()` into exact matches, dropping `->not`, and mistaking a
|
||||
// `toBeTrue()` failure message for an expected value. Unsafe here.
|
||||
UseToMatchArrayRector::class,
|
||||
// Test fixtures rely on "unused" constructors, params and properties
|
||||
// (resolved via the container or read through reflection), so the
|
||||
// dead-code and return-type rules below must not touch the test suite.
|
||||
RemoveEmptyClassMethodRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
RemoveUnusedConstructorParamRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
RemoveUnusedPrivatePropertyRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
AddArrowFunctionReturnTypeRector::class => [
|
||||
__DIR__.'/tests',
|
||||
],
|
||||
])
|
||||
->withPreparedSets(
|
||||
deadCode: true,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, architecture tests, or faster test runs with Test Impact Analysis. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, Tia (--tia), sharding, and all Pest 5 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
@php
|
||||
/** @var \Laravel\Boost\Install\GuidelineAssist $assist */
|
||||
@endphp
|
||||
# Pest Testing 5
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 5 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `{{ $assist->artisanCommand('make:test --pest {name}') }}`.
|
||||
|
||||
The `{name}` argument should include only the path and test name, but should not include the test suite.
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest Feature/SomeFeatureTest') }}` will generate `tests/Feature/Feature/SomeFeatureTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest SomeControllerTest') }}` will generate `tests/Feature/SomeControllerTest.php`
|
||||
- Incorrect: `{{ $assist->artisanCommand('make:test --pest --unit Unit/SomeServiceTest') }}` will generate `tests/Unit/Unit/SomeServiceTest.php`
|
||||
- Correct: `{{ $assist->artisanCommand('make:test --pest --unit SomeServiceTest') }}` will generate `tests/Unit/SomeServiceTest.php`
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
|
||||
|
||||
@boostsnippet("Basic Pest Test Example", "php")
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `{{ $assist->artisanCommand('test --compact --filter=testName') }}`.
|
||||
- Run all tests: `{{ $assist->artisanCommand('test --compact') }}`.
|
||||
- Run file: `{{ $assist->artisanCommand('test --compact tests/Feature/ExampleTest.php') }}`.
|
||||
- Run only tests affected by recent changes (Tia): `./vendor/bin/pest --parallel --tia`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
@boostsnippet("Pest Response Assertion", "php")
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
@boostsnippet("Pest Dataset Example", "php")
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
@endboostsnippet
|
||||
|
||||
## Pest 5 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Tia (Test Impact Analysis) | Rerun only tests affected by recent changes |
|
||||
| Time-Balanced Sharding | Split tests across CI shards by execution time |
|
||||
| New Validation Expectations | `toBeEmail()`, `toBeUlid()`, `toBeIpAddress()`, and more |
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Tia (Test Impact Analysis)
|
||||
|
||||
Tia reruns only tests affected by recent changes and replays cached results for the rest, dramatically reducing suite duration:
|
||||
|
||||
@boostsnippet("Tia Example", "shell")
|
||||
./vendor/bin/pest --parallel --tia
|
||||
@endboostsnippet
|
||||
|
||||
- Replayed tests are not skipped — cached tests store everything they produced, including covered lines and branches.
|
||||
- Detects Laravel, Symfony, Livewire, and Inertia automatically.
|
||||
|
||||
### New Validation Expectations
|
||||
|
||||
Pest 5 ships eight new validation matchers, all supporting `.not` negation:
|
||||
|
||||
@boostsnippet("Pest 5 Validation Expectations", "php")
|
||||
expect('nuno@pestphp.com')->toBeEmail();
|
||||
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
|
||||
expect('192.168.1.1')->toBeIpAddress();
|
||||
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress();
|
||||
expect('example.com')->toBeHostname();
|
||||
expect('example.co.uk')->toBeDomain();
|
||||
expect('Zm9vYmFy')->toBeBase64();
|
||||
expect('deadbeef')->toBeHexadecimal();
|
||||
@endboostsnippet
|
||||
|
||||
### Time-Balanced Sharding
|
||||
|
||||
Distribute tests across CI shards by execution time rather than count:
|
||||
|
||||
@boostsnippet("Pest Sharding Example", "shell")
|
||||
./vendor/bin/pest --update-shards
|
||||
./vendor/bin/pest --shard=1/4
|
||||
@endboostsnippet
|
||||
|
||||
Commit `tests/.pest/shards.json` to the repository so CI shards stay consistent.
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
@boostsnippet("Pest Browser Test Example", "php")
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
@endboostsnippet
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
@boostsnippet("Pest Smoke Testing Example", "php")
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
@endboostsnippet
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
@boostsnippet("Architecture Test Example", "php")
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
@endboostsnippet
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||
@@ -5,6 +5,8 @@
|
||||
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
||||
'INFO' => ['blue', 'INFO'],
|
||||
'ERROR' => ['red', 'ERROR'],
|
||||
'WARN' => ['yellow', 'WARN'],
|
||||
'SUCCESS' => ['green', 'SUCCESS'],
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
@@ -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:^5.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>
|
||||
@@ -53,7 +53,7 @@ abstract class AbstractPreset // @pest-arch-ignore-line
|
||||
/**
|
||||
* Runs the given callback for each namespace.
|
||||
*
|
||||
* @param callable(Expectation<string|null>): ArchExpectation ...$callbacks
|
||||
* @param callable(Expectation<string>): ArchExpectation ...$callbacks
|
||||
*/
|
||||
final public function eachUserNamespace(callable ...$callbacks): void
|
||||
{
|
||||
|
||||
@@ -27,17 +27,21 @@ final class Laravel extends AbstractPreset
|
||||
->ignoring('App\Enums');
|
||||
|
||||
$this->expectations[] = expect('App\Enums')
|
||||
->toBeEnums();
|
||||
->toBeEnums()
|
||||
->ignoring('App\Enums\Concerns');
|
||||
|
||||
$this->expectations[] = expect('App\Features')
|
||||
->toBeClasses();
|
||||
->toBeClasses()
|
||||
->ignoring('App\Features\Concerns');
|
||||
|
||||
$this->expectations[] = expect('App\Features')
|
||||
->toHaveMethod('resolve');
|
||||
->toHaveMethod('resolve')
|
||||
->ignoring('App\Features\Concerns');
|
||||
|
||||
$this->expectations[] = expect('App\Exceptions')
|
||||
->classes()
|
||||
->toImplement('Throwable');
|
||||
->toImplement('Throwable')
|
||||
->ignoring('App\Exceptions\Handler');
|
||||
|
||||
$this->expectations[] = expect('App')
|
||||
->not->toImplement(Throwable::class)
|
||||
@@ -65,6 +69,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveSuffix('Request');
|
||||
|
||||
$this->expectations[] = expect('App\Http\Requests')
|
||||
->classes()
|
||||
->toExtend('Illuminate\Foundation\Http\FormRequest');
|
||||
|
||||
$this->expectations[] = expect('App\Http\Requests')
|
||||
@@ -114,6 +119,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveMethod('handle');
|
||||
|
||||
$this->expectations[] = expect('App\Notifications')
|
||||
->classes()
|
||||
->toExtend('Illuminate\Notifications\Notification');
|
||||
|
||||
$this->expectations[] = expect('App')
|
||||
@@ -124,6 +130,7 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveSuffix('ServiceProvider');
|
||||
|
||||
$this->expectations[] = expect('App\Providers')
|
||||
->classes()
|
||||
->toExtend('Illuminate\Support\ServiceProvider');
|
||||
|
||||
$this->expectations[] = expect('App\Providers')
|
||||
@@ -146,10 +153,10 @@ final class Laravel extends AbstractPreset
|
||||
->toHaveSuffix('Controller');
|
||||
|
||||
$this->expectations[] = expect('App\Http')
|
||||
->toOnlyBeUsedIn('App\Http');
|
||||
->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
|
||||
|
||||
$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([
|
||||
'dd',
|
||||
@@ -159,5 +166,15 @@ final class Laravel extends AbstractPreset
|
||||
'exit',
|
||||
'ray',
|
||||
])->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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ final class Security extends AbstractPreset
|
||||
'create_function',
|
||||
'unserialize',
|
||||
'extract',
|
||||
'parse_str',
|
||||
'mb_parse_str',
|
||||
'dl',
|
||||
'assert',
|
||||
|
||||
@@ -21,6 +21,7 @@ final class Strict extends AbstractPreset
|
||||
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHaveProtectedMethods(),
|
||||
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeAbstract(),
|
||||
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictTypes(),
|
||||
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictEquality(),
|
||||
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->toBeFinal(),
|
||||
);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ final class BootExcludeList implements Bootstrapper
|
||||
*
|
||||
* @var array<int, non-empty-string>
|
||||
*/
|
||||
private const EXCLUDE_LIST = [
|
||||
private const array EXCLUDE_LIST = [
|
||||
'bin',
|
||||
'overrides',
|
||||
'resources',
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Bootstrappers;
|
||||
|
||||
use Pest\Contracts\Bootstrapper;
|
||||
use Pest\Exceptions\FatalException;
|
||||
use Pest\Support\DatasetInfo;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
@@ -24,7 +25,7 @@ final class BootFiles implements Bootstrapper
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const STRUCTURE = [
|
||||
private const array STRUCTURE = [
|
||||
'Expectations',
|
||||
'Expectations.php',
|
||||
'Helpers',
|
||||
@@ -40,6 +41,10 @@ final class BootFiles implements Bootstrapper
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$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) {
|
||||
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
|
||||
|
||||
@@ -78,7 +83,7 @@ final class BootFiles implements Bootstrapper
|
||||
|
||||
private function bootDatasets(string $testsPath): void
|
||||
{
|
||||
assert(strlen($testsPath) > 0);
|
||||
assert($testsPath !== '');
|
||||
|
||||
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');
|
||||
|
||||
|
||||
@@ -15,17 +15,19 @@ final class BootOverrides implements Bootstrapper
|
||||
/**
|
||||
* The list of files to be overridden.
|
||||
*
|
||||
* @var array<string, string>
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const FILES = [
|
||||
'c96b1cb57d7fc8e649f4c13a8abe418c2541bcfab194fb6702b99f777f52ee84' => 'Runner/Filter/NameFilterIterator.php',
|
||||
'a4a43de01f641c6944ee83d963795a46d32b5206b5ab3bbc6cce76e67190acbf' => 'Runner/ResultCache/DefaultResultCache.php',
|
||||
'd0e81317889ad88c707db4b08a94cadee4c9010d05ff0a759f04e71af5efed89' => 'Runner/TestSuiteLoader.php',
|
||||
'3bb609b0d3bf6dee8df8d6cd62a3c8ece823c4bb941eaaae39e3cb267171b9d2' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
||||
'8abdad6413329c6fe0d7d44a8b9926e390af32c0b3123f3720bb9c5bbc6fbb7e' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
||||
'43883b7e5811886cf3731c8ed6304d5a77078d9731e1e505abc2da36bde19f3e' => 'TextUI/TestSuiteFilterProcessor.php',
|
||||
'357d5cd7007f8559b26e1b8cdf43bb6fb15b51b79db981779da6f31b7ec39dad' => 'Event/Value/ThrowableBuilder.php',
|
||||
'676273f1fe483877cf2d95c5aedbf9ae5d6a8e2f4c12d6ce716df6591e6db023' => 'Logging/JUnit/JunitXmlLogger.php',
|
||||
public const array FILES = [
|
||||
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
|
||||
'Runner/Filter/NameFilterIterator.php',
|
||||
'Runner/ResultCache/DefaultResultCache.php',
|
||||
'Runner/TestSuiteLoader.php',
|
||||
'Runner/TestSuiteSorter.php',
|
||||
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
|
||||
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
|
||||
'TextUI/TestSuiteFilterProcessor.php',
|
||||
'Event/Value/ThrowableBuilder.php',
|
||||
'Logging/JUnit/JunitXmlLogger.php',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Bootstrappers;
|
||||
|
||||
use Pest\Contracts\Bootstrapper;
|
||||
use PHPUnit\TextUI\Configuration\Builder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BootPhpUnitConfiguration implements Bootstrapper
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
(new Builder)->build(['pest']);
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,22 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
*
|
||||
* @var array<int, class-string<Subscriber>>
|
||||
*/
|
||||
private const SUBSCRIBERS = [
|
||||
private const array SUBSCRIBERS = [
|
||||
Subscribers\EnsureConfigurationIsAvailable::class,
|
||||
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
|
||||
Subscribers\EnsureKernelDumpIsFlushed::class,
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaIsRunningPestTestsOnly::class,
|
||||
Subscribers\EnsureTiaStarts::class,
|
||||
Subscribers\EnsureTiaEnds::class,
|
||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
|
||||
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
|
||||
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,8 @@ use Closure;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @template T of object
|
||||
*/
|
||||
trait Extendable
|
||||
{
|
||||
@@ -20,6 +22,8 @@ trait Extendable
|
||||
|
||||
/**
|
||||
* Register a new extend.
|
||||
*
|
||||
* @param-closure-this T $extend
|
||||
*/
|
||||
public function extend(string $name, Closure $extend): void
|
||||
{
|
||||
|
||||
@@ -66,6 +66,6 @@ trait Pipeable
|
||||
*/
|
||||
private function pipes(string $name, object $context, string $scope): array
|
||||
{
|
||||
return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
|
||||
return array_map(fn (Closure $pipe): Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
+221
-42
@@ -6,13 +6,25 @@ namespace Pest\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Collectors;
|
||||
use Pest\Plugins\Tia\Enums\ReplayType;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\ExceptionTrace;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\Shell;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
use PHPUnit\Framework\Attributes\PostCondition;
|
||||
use PHPUnit\Framework\IncompleteTest;
|
||||
use PHPUnit\Framework\SkippedTest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use PHPUnit\Framework\TestCase\ExceptionExpectation;
|
||||
use PHPUnit\Framework\TestCase\OutputBuffer;
|
||||
use ReflectionException;
|
||||
use ReflectionFunction;
|
||||
use ReflectionParameter;
|
||||
@@ -61,14 +73,27 @@ trait Testable
|
||||
|
||||
/**
|
||||
* The test's describing, if any.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public ?string $__describing = null;
|
||||
public array $__describing = [];
|
||||
|
||||
/**
|
||||
* Whether the test has ran or not.
|
||||
*/
|
||||
public bool $__ran = false;
|
||||
|
||||
/**
|
||||
* The active replay mode for this test, set in `setUp()` and checked
|
||||
* in `__runTest()` / `tearDown()` to skip the body and after-each.
|
||||
*/
|
||||
private ReplayType $__replay = ReplayType::None;
|
||||
|
||||
/**
|
||||
* The cached assertion count to replay, captured when entering replay mode.
|
||||
*/
|
||||
private int $__replayAssertions = 0;
|
||||
|
||||
/**
|
||||
* The test's test closure.
|
||||
*/
|
||||
@@ -99,27 +124,6 @@ trait Testable
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the test case static properties.
|
||||
*/
|
||||
@@ -146,7 +150,7 @@ trait Testable
|
||||
*/
|
||||
public function __addBeforeAll(?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof \Closure) {
|
||||
if (! $hook instanceof Closure) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,7 +164,7 @@ trait Testable
|
||||
*/
|
||||
public function __addAfterAll(?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof \Closure) {
|
||||
if (! $hook instanceof Closure) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,7 +194,7 @@ trait Testable
|
||||
*/
|
||||
private function __addHook(string $property, ?Closure $hook): void
|
||||
{
|
||||
if (! $hook instanceof \Closure) {
|
||||
if (! $hook instanceof Closure) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,7 +216,11 @@ trait Testable
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,8 +248,6 @@ trait Testable
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
$method->setUp($this);
|
||||
|
||||
$description = $method->description;
|
||||
if ($this->dataName()) {
|
||||
$description = str_contains((string) $description, ':dataset')
|
||||
@@ -272,8 +278,40 @@ trait Testable
|
||||
self::$__latestIssues = $method->issues;
|
||||
self::$__latestPrs = $method->prs;
|
||||
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$status = $tia->getStatus(self::$__filename, $this->valueObjectForEvents()->id());
|
||||
$replay = ReplayType::fromStatus($status);
|
||||
|
||||
if ($replay !== ReplayType::None) {
|
||||
assert($status !== null);
|
||||
|
||||
// Marks the replay before the branches below throw, so `tearDown`
|
||||
// short-circuits for every replayed result — the throwing branches
|
||||
// never reach `parent::setUp`, so no user hook may run after them.
|
||||
$this->__replay = $replay;
|
||||
|
||||
match ($replay) {
|
||||
ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia),
|
||||
ReplayType::Skipped => $this->markTestSkipped($status->message()),
|
||||
ReplayType::Incomplete => $this->markTestIncomplete($status->message()),
|
||||
ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = Container::getInstance()->get(Recorder::class);
|
||||
assert($recorder instanceof Recorder);
|
||||
|
||||
if ($recorder->isActive()) {
|
||||
$recorder->beginTest($this::class, $this->name(), self::$__filename);
|
||||
}
|
||||
|
||||
parent::setUp();
|
||||
|
||||
Collectors::armAll($recorder);
|
||||
|
||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||
|
||||
if ($this->__beforeEach instanceof Closure) {
|
||||
@@ -283,11 +321,51 @@ trait Testable
|
||||
$this->__callClosure($beforeEach, $arguments);
|
||||
}
|
||||
|
||||
private function __beginReplay(ReplayType $replay, Tia $tia): void
|
||||
{
|
||||
$this->__replay = $replay;
|
||||
$this->__replayAssertions = $tia->getAssertionCount($this->valueObjectForEvents()->id());
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test case properties from TestSuite.
|
||||
*/
|
||||
public function __initializeTestCase(): void
|
||||
{
|
||||
// Return if the test case has already been initialized
|
||||
if (isset($this->__test)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$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.
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__replay !== ReplayType::None) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||
|
||||
if ($this->__afterEach instanceof Closure) {
|
||||
@@ -313,10 +391,106 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) {
|
||||
if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) {
|
||||
$this->expectNotToPerformAssertions();
|
||||
}
|
||||
|
||||
$this->addToAssertionCount($this->__replayAssertions);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments = $this->__resolveTestArguments($args);
|
||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||
|
||||
return $this->__callClosure($closure, $arguments);
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
if ($method->flakyTries === null) {
|
||||
return $this->__callClosure($closure, $arguments);
|
||||
}
|
||||
|
||||
$lastException = null;
|
||||
$initialProperties = get_object_vars($this);
|
||||
|
||||
for ($attempt = 1; $attempt <= $method->flakyTries; $attempt++) {
|
||||
try {
|
||||
return $this->__callClosure($closure, $arguments);
|
||||
} catch (Throwable $e) {
|
||||
if ($e instanceof SkippedTest
|
||||
|| $e instanceof IncompleteTest
|
||||
|| $this->__isExpectedException($e)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$lastException = $e;
|
||||
|
||||
if ($attempt < $method->flakyTries) {
|
||||
if ($this->__snapshotChanges !== []) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->tearDown();
|
||||
|
||||
Closure::bind(fn (): array => $this->mockObjects = [], $this, TestCase::class)();
|
||||
|
||||
foreach (array_keys(array_diff_key(get_object_vars($this), $initialProperties)) as $property) {
|
||||
unset($this->{$property});
|
||||
}
|
||||
|
||||
$outputBuffer = Closure::bind(fn () => $this->outputBuffer, $this, TestCase::class)();
|
||||
|
||||
if ($outputBuffer->hasExpectation()) {
|
||||
ob_clean();
|
||||
|
||||
Closure::bind(function (): void {
|
||||
$this->expectedString = null;
|
||||
$this->expectedRegularExpression = null;
|
||||
}, $outputBuffer, OutputBuffer::class)();
|
||||
}
|
||||
|
||||
$this->setUp();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw $lastException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given exception matches PHPUnit's expected exception.
|
||||
*/
|
||||
private function __isExpectedException(Throwable $e): bool
|
||||
{
|
||||
$expectation = Closure::bind(fn () => $this->exceptionExpectation, $this, TestCase::class)();
|
||||
|
||||
$read = fn (string $property): mixed => Closure::bind(fn () => $this->{$property}, $expectation, ExceptionExpectation::class)();
|
||||
|
||||
$expectedClass = $read('expectedException');
|
||||
|
||||
if ($expectedClass !== null) {
|
||||
return $e instanceof $expectedClass;
|
||||
}
|
||||
|
||||
$expectedMessage = $read('expectedMessage');
|
||||
|
||||
if ($expectedMessage !== null) {
|
||||
return str_contains($e->getMessage(), (string) $expectedMessage);
|
||||
}
|
||||
|
||||
$expectedMessageRegex = $read('expectedMessageRegularExpression');
|
||||
|
||||
if ($expectedMessageRegex !== null) {
|
||||
return preg_match($expectedMessageRegex, $e->getMessage()) === 1;
|
||||
}
|
||||
|
||||
$expectedCode = $read('expectedCode');
|
||||
|
||||
if ($expectedCode !== null) {
|
||||
return $e->getCode() === $expectedCode;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,7 +512,8 @@ trait Testable
|
||||
}
|
||||
|
||||
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
|
||||
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest));
|
||||
$testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
|
||||
$testParameterTypes = array_values($testParameterTypesByName);
|
||||
|
||||
if (count($arguments) !== 1) {
|
||||
foreach ($arguments as $argumentIndex => $argumentValue) {
|
||||
@@ -346,7 +521,11 @@ trait Testable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) {
|
||||
$parameterType = is_string($argumentIndex)
|
||||
? $testParameterTypesByName[$argumentIndex]
|
||||
: $testParameterTypes[$argumentIndex];
|
||||
|
||||
if (in_array($parameterType, [Closure::class, 'callable', 'mixed'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -372,7 +551,7 @@ trait Testable
|
||||
return [$boundDatasetResult];
|
||||
}
|
||||
|
||||
return array_values($boundDatasetResult);
|
||||
return $boundDatasetResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,15 +611,7 @@ trait Testable
|
||||
return;
|
||||
}
|
||||
|
||||
if (count($this->__snapshotChanges) === 1) {
|
||||
$this->markTestIncomplete($this->__snapshotChanges[0]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges));
|
||||
|
||||
$this->markTestIncomplete($messages);
|
||||
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,7 +635,7 @@ trait Testable
|
||||
*/
|
||||
public static function getLatestPrintableTestCaseMethodName(): string
|
||||
{
|
||||
return self::$__latestDescription;
|
||||
return self::$__latestDescription ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,4 +650,12 @@ trait Testable
|
||||
'notes' => self::$__latestNotes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a shell for the test case.
|
||||
*/
|
||||
public function shell(): void
|
||||
{
|
||||
Shell::open();
|
||||
}
|
||||
}
|
||||
|
||||
+35
-2
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Pest\PendingCalls\BeforeEachCall;
|
||||
use Pest\PendingCalls\UsesCall;
|
||||
|
||||
/**
|
||||
@@ -32,7 +33,7 @@ final readonly class Configuration
|
||||
*/
|
||||
public function in(string ...$targets): UsesCall
|
||||
{
|
||||
return (new UsesCall($this->filename, []))->in(...$targets);
|
||||
return new UsesCall($this->filename, [])->in(...$targets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,15 @@ final readonly class Configuration
|
||||
*/
|
||||
public function group(string ...$groups): UsesCall
|
||||
{
|
||||
return (new UsesCall($this->filename, []))->group(...$groups);
|
||||
return new UsesCall($this->filename, [])->group(...$groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all tests in the current file to be run exclusively.
|
||||
*/
|
||||
public function only(): void
|
||||
{
|
||||
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +111,30 @@ final readonly class Configuration
|
||||
return Configuration\Project::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the browser configuration.
|
||||
*/
|
||||
public function browser(): Browser\Configuration
|
||||
{
|
||||
return new Browser\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the evals configuration.
|
||||
*/
|
||||
public function evals(): Evals\Configuration // @phpstan-ignore-line
|
||||
{
|
||||
return new Evals\Configuration; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TIA (Test Impact Analysis) configuration.
|
||||
*/
|
||||
public function tia(): Plugins\Tia\Configuration
|
||||
{
|
||||
return new Plugins\Tia\Configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxies calls to the uses method.
|
||||
*
|
||||
|
||||
@@ -59,12 +59,15 @@ final class Project
|
||||
/**
|
||||
* Sets the test project to GitLab.
|
||||
*/
|
||||
public function gitlab(string $project): self
|
||||
public function gitlab(string $project, string $hostname = 'gitlab.com'): self
|
||||
{
|
||||
$this->issues = "https://gitlab.com/{$project}/issues/%s";
|
||||
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
|
||||
// Simple way to ensure only the host is used
|
||||
$hostname = parse_url($hostname, PHP_URL_HOST) ?? $hostname;
|
||||
|
||||
$this->assignees = 'https://gitlab.com/%s';
|
||||
$this->issues = "https://{$hostname}/{$project}/-/work_items/%s";
|
||||
$this->prs = "https://{$hostname}/{$project}/-/merge_requests/%s";
|
||||
|
||||
$this->assignees = "https://{$hostname}/%s";
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ final readonly class Help
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const HELP_MESSAGES = [
|
||||
private const array HELP_MESSAGES = [
|
||||
'<comment>Pest Options:</comment>',
|
||||
' <info>--init</info> Initialise a standard Pest configuration',
|
||||
' <info>--coverage</info> Enable coverage and output to standard output',
|
||||
|
||||
@@ -22,10 +22,14 @@ final readonly class Thanks
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const FUNDING_MESSAGES = [
|
||||
private const array FUNDING_MESSAGES = [
|
||||
'Star' => 'https://github.com/pestphp/pest',
|
||||
'News' => 'https://twitter.com/pestphp',
|
||||
'Videos' => 'https://youtube.com/@nunomaduro',
|
||||
'YouTube' => 'https://youtube.com/@nunomaduro',
|
||||
'TikTok' => 'https://tiktok.com/@enunomaduro',
|
||||
'Twitch' => 'https://twitch.tv/nunomaduro',
|
||||
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
|
||||
'Instagram' => 'https://instagram.com/enunomaduro',
|
||||
'X' => 'https://x.com/enunomaduro',
|
||||
'Sponsor' => 'https://github.com/sponsors/nunomaduro',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
interface Restarter
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function maybeRestart(string $projectRoot, array $arguments): void;
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class AfterAllAlreadyExist extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterAllWithinDescribe extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class AfterBeforeTestFunction extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
|
||||
parent::__construct('After method cannot be used with before the [test|it] functions in the filename ['.$filename.'].');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Pest\Support\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $headline,
|
||||
private readonly string $hint,
|
||||
private readonly bool $hasAnchor = false,
|
||||
) {
|
||||
parent::__construct($headline);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
View::renderUsing($output);
|
||||
|
||||
if (! $this->hasAnchor) {
|
||||
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||
$output->writeln('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->renderChild($output, $this->headline);
|
||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||
$output->writeln('');
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function renderChild(OutputInterface $output, string $text): void
|
||||
{
|
||||
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllAlreadyExist extends InvalidArgumentException implements Ex
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll already exists in the filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class BeforeAllWithinDescribe extends InvalidArgumentException implements
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
|
||||
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename [%s].', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetAlreadyExists extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $name, string $scope)
|
||||
{
|
||||
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
|
||||
parent::__construct(sprintf('A dataset with the name [%s] already exists in scope [%s].', $name, $scope));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class DatasetDoesNotExist extends InvalidArgumentException implements Exce
|
||||
*/
|
||||
public function __construct(string $name)
|
||||
{
|
||||
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
parent::__construct(sprintf("A dataset with the name [%s] does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ final class DatasetMissing extends BadFunctionCallException implements Exception
|
||||
public function __construct(string $file, string $name, array $arguments)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
"A test with the description '%s' has %d argument(s) ([%s]) and no dataset(s) provided in %s",
|
||||
'A test with the description [%s] has [%d] argument(s) ([%s]) and no dataset(s) provided in [%s]',
|
||||
$name,
|
||||
count($arguments),
|
||||
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class DatasetProviderError extends RuntimeException
|
||||
{
|
||||
public function __construct(Throwable $previous)
|
||||
{
|
||||
parent::__construct($previous->getMessage(), (int) $previous->getCode(), $previous);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,6 @@ final class FileOrFolderNotFound extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
public function __construct(string $filename)
|
||||
{
|
||||
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
|
||||
parent::__construct(sprintf('The file or folder with the name [%s] could not be found.', $filename));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InvalidTestClassName extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Creates a new Exception instance for the given class name.
|
||||
*/
|
||||
public static function fromClassName(string $filename, string $className): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the class [%s], which is not a valid PHP class name. Please rename the test file.',
|
||||
$filename,
|
||||
$className,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Exception instance for the given namespace.
|
||||
*/
|
||||
public static function fromNamespace(string $filename, string $namespace, string $part): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'The test file [%s] would create the namespace [%s], which is not a valid PHP namespace, as [%s] may not be used as a namespace name. Please rename the folder in question.',
|
||||
$filename,
|
||||
$namespace,
|
||||
$part,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=blue> INFO </> No affected tests found.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ final class ShouldNotHappen extends RuntimeException
|
||||
$message = $exception->getMessage();
|
||||
|
||||
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
|
||||
PHP version: %s
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestAlreadyExist extends InvalidArgumentException implements Excepti
|
||||
*/
|
||||
public function __construct(string $fileName, string $description)
|
||||
{
|
||||
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
|
||||
parent::__construct(sprintf('A test with the description [%s] already exists in the filename [%s].', $description, $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ final class TestCaseAlreadyInUse extends InvalidArgumentException implements Exc
|
||||
*/
|
||||
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`',
|
||||
$newOne, $folder, $inUse));
|
||||
parent::__construct(sprintf(
|
||||
'Test case [%s] can not be used. The folder [%s] already uses the test case [%s].',
|
||||
$newOne,
|
||||
$folder,
|
||||
$inUse,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implem
|
||||
*/
|
||||
public function __construct(string $testCaseClass)
|
||||
{
|
||||
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
|
||||
parent::__construct(sprintf('The class [%s] was not found.', $testCaseClass));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ final class TestClosureMustNotBeStatic extends InvalidArgumentException implemen
|
||||
{
|
||||
parent::__construct(
|
||||
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->filename
|
||||
)
|
||||
|
||||
@@ -19,6 +19,6 @@ final class TestDescriptionMissing extends InvalidArgumentException implements E
|
||||
*/
|
||||
public function __construct(string $fileName)
|
||||
{
|
||||
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
|
||||
parent::__construct(sprintf('Test description is missing in the filename [%s].', $fileName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $className, string $filename)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
|
||||
$className,
|
||||
$filename,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires Pest tests.',
|
||||
'',
|
||||
sprintf(' Encountered PHPUnit class <fg=yellow>%s</>', $this->className),
|
||||
sprintf(' in <fg=gray>%s</>.', $this->file),
|
||||
'',
|
||||
' Convert it to a Pest test, or run without Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Exceptions;
|
||||
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TiaRequiresRepositoryRoot extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
public function __construct(private readonly string $subdirectoryPrefix)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'Tia mode requires the project root to be the git repository root, but it sits in the subdirectory [%s] of a larger repo.',
|
||||
$this->subdirectoryPrefix,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> Tia mode requires the git repository root.',
|
||||
'',
|
||||
sprintf(' This project sits in a subdirectory of a larger repo <fg=yellow>%s</>.', $this->subdirectoryPrefix),
|
||||
'',
|
||||
' Give the project its own git repository to use Tia.',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+123
-41
@@ -18,6 +18,7 @@ use Pest\Arch\Expectations\ToOnlyUse;
|
||||
use Pest\Arch\Expectations\ToUse;
|
||||
use Pest\Arch\Expectations\ToUseNothing;
|
||||
use Pest\Arch\PendingArchExpectation;
|
||||
use Pest\Arch\Support\Composer;
|
||||
use Pest\Arch\Support\FileLineFinder;
|
||||
use Pest\Concerns\Extendable;
|
||||
use Pest\Concerns\Pipeable;
|
||||
@@ -52,7 +53,9 @@ use ReflectionProperty;
|
||||
*/
|
||||
final class Expectation
|
||||
{
|
||||
/** @use Extendable<self<TValue>> */
|
||||
use Extendable;
|
||||
|
||||
use Pipeable;
|
||||
use Retrievable;
|
||||
|
||||
@@ -109,7 +112,7 @@ final class Expectation
|
||||
if (function_exists('dump')) {
|
||||
dump($this->value, ...$arguments);
|
||||
} else {
|
||||
var_dump($this->value);
|
||||
var_dump($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -117,16 +120,22 @@ final class Expectation
|
||||
|
||||
/**
|
||||
* Dump the expectation value and end the script.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function dd(mixed ...$arguments): void
|
||||
public function dd(mixed ...$arguments): never
|
||||
{
|
||||
if (function_exists('dd')) {
|
||||
dd($this->value, ...$arguments);
|
||||
}
|
||||
|
||||
var_dump($this->value);
|
||||
if (getenv('PARATEST') !== false || isset($_SERVER['COLLISION_PRINTER'])) {
|
||||
ob_start();
|
||||
var_dump($this->value, ...$arguments);
|
||||
$output = ob_get_clean();
|
||||
|
||||
throw new ExpectationFailedException($output);
|
||||
}
|
||||
|
||||
var_dump($this->value, ...$arguments);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
@@ -134,7 +143,7 @@ final class Expectation
|
||||
/**
|
||||
* Dump the expectation value when the result of the condition is truthy.
|
||||
*
|
||||
* @param (\Closure(TValue): bool)|bool $condition
|
||||
* @param (Closure(TValue): bool)|bool $condition
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
|
||||
@@ -151,7 +160,7 @@ final class Expectation
|
||||
/**
|
||||
* Dump the expectation value when the result of the condition is falsy.
|
||||
*
|
||||
* @param (\Closure(TValue): bool)|bool $condition
|
||||
* @param (Closure(TValue): bool)|bool $condition
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
|
||||
@@ -223,7 +232,7 @@ final class Expectation
|
||||
throw new BadMethodCallException('Expectation value is not iterable.');
|
||||
}
|
||||
|
||||
if (count($callbacks) == 0) {
|
||||
if ($callbacks === []) {
|
||||
throw new InvalidArgumentException('No sequence expectations defined.');
|
||||
}
|
||||
|
||||
@@ -235,7 +244,7 @@ final class Expectation
|
||||
if ($callbacks[$index] instanceof Closure) {
|
||||
$callbacks[$index](new self($value), new self($key));
|
||||
} else {
|
||||
(new self($value))->toEqual($callbacks[$index]);
|
||||
new self($value)->toEqual($callbacks[$index]);
|
||||
}
|
||||
|
||||
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
|
||||
@@ -264,7 +273,7 @@ final class Expectation
|
||||
$matched = false;
|
||||
|
||||
foreach ($expressions as $key => $callback) {
|
||||
if ($subject != $key) {
|
||||
if ($subject != $key) { // @pest-arch-ignore-line
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -330,7 +339,7 @@ final class Expectation
|
||||
* @param array<int, mixed> $parameters
|
||||
* @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 (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) {
|
||||
@@ -355,6 +364,10 @@ final class Expectation
|
||||
$reflectionClosure = new \ReflectionFunction($closure);
|
||||
$expectation = $reflectionClosure->getClosureThis();
|
||||
|
||||
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
|
||||
return $closure(...$parameters);
|
||||
}
|
||||
|
||||
assert(is_object($expectation));
|
||||
|
||||
ExpectationPipeline::for($closure)
|
||||
@@ -380,7 +393,7 @@ final class Expectation
|
||||
if (self::hasExtend($name)) {
|
||||
$extend = self::$extends[$name]->bindTo($this, Expectation::class);
|
||||
|
||||
if ($extend != false) {
|
||||
if ($extend != false) { // @pest-arch-ignore-line
|
||||
return $extend;
|
||||
}
|
||||
}
|
||||
@@ -393,7 +406,7 @@ final class Expectation
|
||||
*
|
||||
* @return Expectation<TValue>|OppositeExpectation<TValue>|EachExpectation<TValue>|HigherOrderExpectation<Expectation<TValue>, TValue|null>|TValue
|
||||
*/
|
||||
public function __get(string $name)
|
||||
public function __get(string $name): mixed
|
||||
{
|
||||
if (! self::hasMethod($name)) {
|
||||
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) {
|
||||
@@ -509,12 +522,25 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$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',
|
||||
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.
|
||||
*/
|
||||
@@ -522,7 +548,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$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',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -535,7 +561,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$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',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -548,7 +574,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isTrait(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isTrait(),
|
||||
'to be trait',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -569,7 +595,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isAbstract(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isAbstract(),
|
||||
'to be abstract',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -586,7 +612,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$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)),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -650,6 +676,41 @@ final class Expectation
|
||||
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is cased correctly.
|
||||
*/
|
||||
public function toBeCasedCorrectly(): ArchExpectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object): bool {
|
||||
if (! isset($object->reflectionClass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$realPath = realpath($object->path);
|
||||
|
||||
if ($realPath === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (Composer::allNamespacesWithDirectories() as $directory => $namespace) {
|
||||
if (str_starts_with($realPath, $directory)) {
|
||||
$relativePath = substr($realPath, strlen($directory) + 1);
|
||||
$relativePath = explode('.', $relativePath)[0];
|
||||
$classFromPath = $namespace.'\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
|
||||
|
||||
return $classFromPath === $object->reflectionClass->getName();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
'to be cased correctly',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given expectation target is enum.
|
||||
*/
|
||||
@@ -657,7 +718,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isEnum(),
|
||||
'to be enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -699,7 +760,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isInterface(),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isInterface(),
|
||||
'to be interface',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -720,7 +781,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$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),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -760,7 +821,26 @@ final class Expectation
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($traits): bool {
|
||||
foreach ($traits as $trait) {
|
||||
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
if (isset($object->reflectionClass) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentClass = $object->reflectionClass;
|
||||
$usedTraits = [];
|
||||
|
||||
do {
|
||||
$classTraits = $currentClass->getTraits();
|
||||
foreach ($classTraits as $traitReflection) {
|
||||
$usedTraits[$traitReflection->getName()] = $traitReflection->getName();
|
||||
|
||||
$nestedTraits = $traitReflection->getTraits();
|
||||
foreach ($nestedTraits as $nestedTrait) {
|
||||
$usedTraits[$nestedTrait->getName()] = $nestedTrait->getName();
|
||||
}
|
||||
}
|
||||
} while ($currentClass = $currentClass->getParentClass());
|
||||
|
||||
if (! array_key_exists($trait, $usedTraits)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -779,7 +859,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() === [],
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getInterfaceNames() === [],
|
||||
'to implement nothing',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -796,7 +876,8 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$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()) === [],
|
||||
"to only implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
@@ -810,7 +891,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$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}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -823,7 +904,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$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}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -840,15 +921,7 @@ final class Expectation
|
||||
|
||||
return Targeted::make(
|
||||
$this,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if (! $object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
|
||||
"to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -872,6 +945,14 @@ final class Expectation
|
||||
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.
|
||||
*/
|
||||
@@ -915,7 +996,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->hasMethod('__invoke'),
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod('__invoke'),
|
||||
'to be invokable',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
@@ -1024,7 +1105,7 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) !== [],
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [],
|
||||
"to have attribute '{$attribute}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -1053,9 +1134,10 @@ final class Expectation
|
||||
{
|
||||
return Targeted::make(
|
||||
$this,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum()
|
||||
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
|
||||
&& $object->reflectionClass->isEnum()
|
||||
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
|
||||
'to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ final class HigherOrderExpectation
|
||||
* Dynamically calls methods on the class with the given arguments.
|
||||
*
|
||||
* @param array<int, mixed> $arguments
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
@@ -127,7 +127,7 @@ final class HigherOrderExpectation
|
||||
/**
|
||||
* Accesses properties in the value or in the expectation.
|
||||
*
|
||||
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
|
||||
* @return self<TOriginalValue, mixed>
|
||||
*/
|
||||
public function __get(string $name): self
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ use Pest\Arch\PendingArchExpectation;
|
||||
use Pest\Arch\SingleArchExpectation;
|
||||
use Pest\Arch\Support\FileLineFinder;
|
||||
use Pest\Exceptions\InvalidExpectation;
|
||||
use Pest\Exceptions\MissingDependency;
|
||||
use Pest\Expectation;
|
||||
use Pest\Support\Arr;
|
||||
use Pest\Support\Exporter;
|
||||
@@ -24,6 +25,7 @@ use PHPUnit\Framework\AssertionFailedError;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
use ReflectionMethod;
|
||||
use ReflectionProperty;
|
||||
use Spoofchecker;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
@@ -74,7 +76,10 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
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),
|
||||
), is_string($targets) ? [$targets] : $targets));
|
||||
}
|
||||
@@ -84,8 +89,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) !== $permissions,
|
||||
sprintf('permissions not to be [%s]', $permissions),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
|
||||
@@ -105,8 +113,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveMethodsDocumented(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| array_filter(
|
||||
Reflection::getMethodsFromReflectionClass($object->reflectionClass),
|
||||
@@ -124,8 +135,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHavePropertiesDocumented(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| array_filter(
|
||||
Reflection::getPropertiesFromReflectionClass($object->reflectionClass),
|
||||
@@ -144,22 +158,44 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toUseStrictTypes(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
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)),
|
||||
'not to use strict types',
|
||||
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.
|
||||
*/
|
||||
public function toBeFinal(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isFinal(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isFinal()),
|
||||
'not to be final',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -170,9 +206,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeReadonly(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isReadOnly()) && assert(true), // @phpstan-ignore-line
|
||||
'not to be readonly',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -183,9 +222,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeTrait(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isTrait(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isTrait(),
|
||||
'not to be trait',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -204,9 +246,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeAbstract(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isAbstract(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isAbstract(),
|
||||
'not to be abstract',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -221,17 +266,42 @@ final readonly class OppositeExpectation
|
||||
{
|
||||
$methods = is_array($method) ? $method : [$method];
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => array_filter(
|
||||
$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),
|
||||
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
|
||||
{
|
||||
if (! class_exists(Spoofchecker::class)) {
|
||||
throw new MissingDependency(__FUNCTION__, 'ext-intl >= 2.0');
|
||||
}
|
||||
|
||||
$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.
|
||||
*
|
||||
@@ -253,8 +323,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($methods, &$state): bool {
|
||||
$reflectionMethods = isset($object->reflectionClass)
|
||||
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC)
|
||||
@@ -273,7 +346,7 @@ final readonly class OppositeExpectation
|
||||
$methods === []
|
||||
? 'not to have public 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 +369,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($methods, &$state): bool {
|
||||
$reflectionMethods = isset($object->reflectionClass)
|
||||
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED)
|
||||
@@ -316,7 +392,7 @@ final readonly class OppositeExpectation
|
||||
$methods === []
|
||||
? 'not to have protected 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 +415,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$state = new stdClass;
|
||||
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
function (ObjectDescription $object) use ($methods, &$state): bool {
|
||||
$reflectionMethods = isset($object->reflectionClass)
|
||||
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE)
|
||||
@@ -359,7 +438,7 @@ final readonly class OppositeExpectation
|
||||
$methods === []
|
||||
? 'not to have private 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 +455,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeEnum(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isEnum(),
|
||||
'not to be enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -397,8 +479,11 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeClass(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => ! class_exists($object->name),
|
||||
'not to be class',
|
||||
FileLineFinder::where(fn (string $line): bool => true),
|
||||
@@ -418,9 +503,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeInterface(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isInterface(),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isInterface(),
|
||||
'not to be interface',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -439,9 +527,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toExtend(string $class): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isSubclassOf($class),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isSubclassOf($class),
|
||||
sprintf("not to extend '%s'", $class),
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -452,9 +543,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toExtendNothing(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() !== false,
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getParentClass() !== false,
|
||||
'to extend a class',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -477,17 +571,12 @@ final readonly class OppositeExpectation
|
||||
{
|
||||
$traits = is_array($traits) ? $traits : [$traits];
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
function (ObjectDescription $object) use ($traits): bool {
|
||||
foreach ($traits as $trait) {
|
||||
if (in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return true;
|
||||
},
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! isset($object->reflectionClass) || ! in_array($trait, $object->reflectionClass->getTraitNames(), true)),
|
||||
"not to use traits '".implode("', '", $traits)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -502,17 +591,12 @@ final readonly class OppositeExpectation
|
||||
{
|
||||
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
function (ObjectDescription $object) use ($interfaces): bool {
|
||||
foreach ($interfaces as $interface) {
|
||||
if ($object->reflectionClass->implementsInterface($interface)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return true;
|
||||
},
|
||||
return Targeted::make(
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)),
|
||||
"not to implement '".implode("', '", $interfaces)."'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -523,9 +607,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toImplementNothing(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() !== [],
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getInterfaceNames() !== [],
|
||||
'to implement an interface',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -544,9 +631,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHavePrefix(string $prefix): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
|
||||
"not to have prefix '{$prefix}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -557,9 +647,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveSuffix(string $suffix): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_ends_with($object->reflectionClass->getName(), $suffix),
|
||||
"not to have suffix '{$suffix}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
@@ -586,7 +679,10 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
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 +692,10 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
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),
|
||||
), is_string($targets) ? [$targets] : $targets));
|
||||
}
|
||||
@@ -619,9 +718,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toBeInvokable(): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->hasMethod('__invoke'),
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->hasMethod('__invoke'),
|
||||
'to not be invokable',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
@@ -632,9 +734,12 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
public function toHaveAttribute(string $attribute): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) === [],
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getAttributes($attribute) === [],
|
||||
"to not have attribute '{$attribute}'",
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
|
||||
);
|
||||
@@ -693,13 +798,11 @@ final readonly class OppositeExpectation
|
||||
|
||||
$exporter = Exporter::default();
|
||||
|
||||
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
|
||||
|
||||
throw new ExpectationFailedException(sprintf(
|
||||
'Expecting %s not %s %s.',
|
||||
$toString($this->original->value),
|
||||
$exporter->shortenedExport($this->original->value),
|
||||
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
|
||||
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -724,11 +827,15 @@ final readonly class OppositeExpectation
|
||||
*/
|
||||
private function toBeBackedEnum(string $backingType): ArchExpectation
|
||||
{
|
||||
/** @var Expectation<array<int, string>|string> $original */
|
||||
$original = $this->original;
|
||||
|
||||
return Targeted::make(
|
||||
$this->original,
|
||||
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum()
|
||||
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|
||||
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
$original,
|
||||
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|
||||
|| ! $object->reflectionClass->isEnum()
|
||||
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|
||||
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
|
||||
'not to be '.$backingType.' backed enum',
|
||||
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
|
||||
);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Factories\Covers;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoversNothing {}
|
||||
@@ -9,6 +9,7 @@ use Pest\Concerns;
|
||||
use Pest\Contracts\HasPrintableTestCaseName;
|
||||
use Pest\Evaluators\Attributes;
|
||||
use Pest\Exceptions\DatasetMissing;
|
||||
use Pest\Exceptions\InvalidTestClassName;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Exceptions\TestAlreadyExist;
|
||||
use Pest\Exceptions\TestClosureMustNotBeStatic;
|
||||
@@ -17,6 +18,7 @@ use Pest\Factories\Concerns\HigherOrderable;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -58,6 +60,11 @@ final class TestCaseFactory
|
||||
Concerns\Expectable::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The namespace for the test case, overrides the path-based namespace when set.
|
||||
*/
|
||||
public ?string $namespace = null;
|
||||
|
||||
/**
|
||||
* Creates a new Factory instance.
|
||||
*/
|
||||
@@ -88,7 +95,8 @@ final class TestCaseFactory
|
||||
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
|
||||
}
|
||||
|
||||
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
|
||||
$realpath = (string) realpath($filename);
|
||||
$filename = str_replace('\\\\', '\\', addslashes($realpath));
|
||||
$rootPath = TestSuite::getInstance()->rootPath;
|
||||
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
|
||||
|
||||
@@ -110,8 +118,8 @@ final class TestCaseFactory
|
||||
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
|
||||
// Remove escaped quote sequences (maintain namespace)
|
||||
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
|
||||
// Limit to A-Z, a-z, 0-9, '_', '-'.
|
||||
$relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath);
|
||||
// Limit to Unicode letters and numbers.
|
||||
$relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath);
|
||||
|
||||
$classFQN = 'P\\'.$relativePath;
|
||||
|
||||
@@ -126,16 +134,26 @@ final class TestCaseFactory
|
||||
|
||||
$partsFQN = explode('\\', $classFQN);
|
||||
$className = array_pop($partsFQN);
|
||||
$namespace = implode('\\', $partsFQN);
|
||||
$namespace = $this->namespace ?? implode('\\', $partsFQN);
|
||||
$baseClass = sprintf('\%s', $this->class);
|
||||
|
||||
if (trim($className) === '') {
|
||||
$className = 'InvalidTestName'.Str::random();
|
||||
} elseif (! Str::isValidClassName($className)) {
|
||||
throw InvalidTestClassName::fromClassName($this->filename, $className);
|
||||
}
|
||||
|
||||
if ($this->namespace === null) {
|
||||
foreach ($partsFQN as $partFQN) {
|
||||
if (! Str::isValidIdentifier($partFQN)) {
|
||||
throw InvalidTestClassName::fromNamespace($this->filename, $namespace, $partFQN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->attributes = [
|
||||
new Attribute(
|
||||
\PHPUnit\Framework\Attributes\TestDox::class,
|
||||
TestDox::class,
|
||||
[$this->filename],
|
||||
),
|
||||
...$this->attributes,
|
||||
@@ -143,6 +161,8 @@ final class TestCaseFactory
|
||||
|
||||
$attributesCode = Attributes::code($this->attributes);
|
||||
|
||||
$filenameLiteral = var_export($realpath, true);
|
||||
|
||||
$methodsCode = implode('', array_map(
|
||||
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
|
||||
$methods
|
||||
@@ -152,6 +172,7 @@ final class TestCaseFactory
|
||||
$classCode = <<<PHP
|
||||
namespace $namespace;
|
||||
|
||||
use Pest\Exceptions\DatasetProviderError as __PestDatasetProviderError;
|
||||
use Pest\Repositories\DatasetsRepository as __PestDatasets;
|
||||
use Pest\TestSuite as __PestTestSuite;
|
||||
|
||||
@@ -160,13 +181,13 @@ final class TestCaseFactory
|
||||
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
|
||||
$traitsCode
|
||||
|
||||
private static \$__filename = '$filename';
|
||||
public static \$__filename = $filenameLiteral;
|
||||
|
||||
$methodsCode
|
||||
}
|
||||
PHP;
|
||||
|
||||
eval($classCode); // @phpstan-ignore-line
|
||||
eval($classCode);
|
||||
} catch (ParseError $caught) {
|
||||
throw new RuntimeException(sprintf(
|
||||
"Unable to create test case for test file at %s. \n %s",
|
||||
@@ -191,7 +212,7 @@ final class TestCaseFactory
|
||||
|
||||
if (
|
||||
$method->closure instanceof \Closure &&
|
||||
(new \ReflectionFunction($method->closure))->isStatic()
|
||||
new \ReflectionFunction($method->closure)->isStatic()
|
||||
) {
|
||||
|
||||
throw new TestClosureMustNotBeStatic($method);
|
||||
|
||||
@@ -9,10 +9,14 @@ use Pest\Evaluators\Attributes;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Factories\Concerns\HigherOrderable;
|
||||
use Pest\Repositories\DatasetsRepository;
|
||||
use Pest\Support\Description;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Depends;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
@@ -31,8 +35,10 @@ final class TestCaseMethodFactory
|
||||
|
||||
/**
|
||||
* The test's describing, if any.
|
||||
*
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
public ?string $describing = null;
|
||||
public array $describing = [];
|
||||
|
||||
/**
|
||||
* The test's description, if any.
|
||||
@@ -44,6 +50,11 @@ final class TestCaseMethodFactory
|
||||
*/
|
||||
public int $repetitions = 1;
|
||||
|
||||
/**
|
||||
* The test's number of flaky retry tries.
|
||||
*/
|
||||
public ?int $flakyTries = null;
|
||||
|
||||
/**
|
||||
* Determines if the test is a "todo".
|
||||
*/
|
||||
@@ -153,7 +164,7 @@ final class TestCaseMethodFactory
|
||||
assert($testCase instanceof TestCaseFactory);
|
||||
$method = $this;
|
||||
|
||||
return function (...$arguments) use ($testCase, $method, $closure): mixed { // @phpstan-ignore-line
|
||||
return function (...$arguments) use ($testCase, $method, $closure): mixed {
|
||||
/* @var TestCase $this */
|
||||
$testCase->proxies->proxy($this);
|
||||
$method->proxies->proxy($this);
|
||||
@@ -190,21 +201,21 @@ final class TestCaseMethodFactory
|
||||
|
||||
$this->attributes = [
|
||||
new Attribute(
|
||||
\PHPUnit\Framework\Attributes\Test::class,
|
||||
Test::class,
|
||||
[],
|
||||
),
|
||||
new Attribute(
|
||||
\PHPUnit\Framework\Attributes\TestDox::class,
|
||||
TestDox::class,
|
||||
[str_replace('*/', '{@*}', $this->description)],
|
||||
),
|
||||
...$this->attributes,
|
||||
];
|
||||
|
||||
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(
|
||||
\PHPUnit\Framework\Attributes\Depends::class,
|
||||
Depends::class,
|
||||
[$depend],
|
||||
);
|
||||
}
|
||||
@@ -224,6 +235,10 @@ final class TestCaseMethodFactory
|
||||
$attributesCode
|
||||
public function $methodName(...\$arguments)
|
||||
{
|
||||
if (count(\$arguments) === 1 && \$arguments[0] instanceof __PestDatasetProviderError) {
|
||||
throw \$arguments[0]->getPrevious() ?? \$arguments[0];
|
||||
}
|
||||
|
||||
return \$this->__runTest(
|
||||
\$this->__test,
|
||||
...\$arguments,
|
||||
@@ -250,7 +265,11 @@ final class TestCaseMethodFactory
|
||||
|
||||
public static function $dataProviderName()
|
||||
{
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
try {
|
||||
return __PestDatasets::get(self::\$__filename, "$methodName");
|
||||
} catch (\Throwable \$throwable) {
|
||||
return [[new __PestDatasetProviderError(\$throwable)]];
|
||||
}
|
||||
}
|
||||
|
||||
EOF;
|
||||
|
||||
+107
-38
@@ -2,11 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Concerns\Expectable;
|
||||
use Pest\Browser\Api\ArrayablePendingAwaitablePage;
|
||||
use Pest\Browser\Api\PendingAwaitablePage;
|
||||
use Pest\Configuration;
|
||||
use Pest\Exceptions\AfterAllWithinDescribe;
|
||||
use Pest\Exceptions\BeforeAllWithinDescribe;
|
||||
use Pest\Expectation;
|
||||
use Pest\Installers\PluginBrowser;
|
||||
use Pest\Mutate\Contracts\MutationTestRunner;
|
||||
use Pest\Mutate\Repositories\ConfigurationRepository;
|
||||
use Pest\PendingCalls\AfterEachCall;
|
||||
@@ -18,6 +20,7 @@ use Pest\Repositories\DatasetsRepository;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\DatasetInfo;
|
||||
use Pest\Support\Description;
|
||||
use Pest\Support\HigherOrderTapProxy;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -43,8 +46,8 @@ if (! function_exists('beforeAll')) {
|
||||
*/
|
||||
function beforeAll(Closure $closure): void
|
||||
{
|
||||
if (! is_null(DescribeCall::describing())) {
|
||||
$filename = Backtrace::file();
|
||||
if (DescribeCall::describing() !== []) {
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
throw new BeforeAllWithinDescribe($filename);
|
||||
}
|
||||
@@ -57,13 +60,11 @@ if (! function_exists('beforeEach')) {
|
||||
/**
|
||||
* Runs the given closure before each test in the current file.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
*
|
||||
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
|
||||
* @param-closure-this TestCall $closure
|
||||
*/
|
||||
function beforeEach(?Closure $closure = null): BeforeEachCall
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
|
||||
}
|
||||
@@ -88,14 +89,12 @@ if (! function_exists('describe')) {
|
||||
* Adds the given closure as a group of tests. The first argument
|
||||
* is the group description; the second argument is a closure
|
||||
* that contains the group tests.
|
||||
*
|
||||
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
|
||||
*/
|
||||
function describe(string $description, Closure $tests): DescribeCall
|
||||
{
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new DescribeCall(TestSuite::getInstance(), $filename, $description, $tests);
|
||||
return new DescribeCall(TestSuite::getInstance(), $filename, new Description($description), $tests);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +107,7 @@ if (! function_exists('uses')) {
|
||||
*/
|
||||
function uses(string ...$classAndTraits): UsesCall
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new UsesCall($filename, array_values($classAndTraits));
|
||||
}
|
||||
@@ -120,7 +119,7 @@ if (! function_exists('pest')) {
|
||||
*/
|
||||
function pest(): Configuration
|
||||
{
|
||||
return new Configuration(Backtrace::file());
|
||||
return new Configuration(Backtrace::testFile());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,13 +129,13 @@ if (! function_exists('test')) {
|
||||
* is the test description; the second argument is
|
||||
* a closure that contains the test expectations.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
* @param-closure-this TestCall $closure
|
||||
*
|
||||
* @return Expectable|TestCall|TestCase|mixed
|
||||
* @return ($description is string ? TestCall : HigherOrderTapProxy|TestCall)
|
||||
*/
|
||||
function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall
|
||||
{
|
||||
if ($description === null && TestSuite::getInstance()->test instanceof \PHPUnit\Framework\TestCase) {
|
||||
if ($description === null && TestSuite::getInstance()->test instanceof TestCase) {
|
||||
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
|
||||
}
|
||||
|
||||
@@ -152,34 +151,23 @@ if (! function_exists('it')) {
|
||||
* is the test description; the second argument is
|
||||
* a closure that contains the test expectations.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
*
|
||||
* @return Expectable|TestCall|TestCase|mixed
|
||||
* @param-closure-this TestCall $closure
|
||||
*/
|
||||
function it(string $description, ?Closure $closure = null): TestCall
|
||||
{
|
||||
$description = sprintf('it %s', $description);
|
||||
|
||||
/** @var TestCall $test */
|
||||
$test = test($description, $closure);
|
||||
|
||||
return $test;
|
||||
return test($description, $closure);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('todo')) {
|
||||
/**
|
||||
* Creates a new test that is marked as "todo".
|
||||
*
|
||||
* @return Expectable|TestCall|TestCase|mixed
|
||||
*/
|
||||
function todo(string $description): TestCall
|
||||
{
|
||||
$test = test($description);
|
||||
|
||||
assert($test instanceof TestCall);
|
||||
|
||||
return $test->todo();
|
||||
return test($description)->todo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,13 +175,11 @@ if (! function_exists('afterEach')) {
|
||||
/**
|
||||
* Runs the given closure after each test in the current file.
|
||||
*
|
||||
* @param-closure-this TestCase $closure
|
||||
*
|
||||
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|TestCall|mixed
|
||||
* @param-closure-this TestCall $closure
|
||||
*/
|
||||
function afterEach(?Closure $closure = null): AfterEachCall
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
|
||||
}
|
||||
@@ -205,8 +191,8 @@ if (! function_exists('afterAll')) {
|
||||
*/
|
||||
function afterAll(Closure $closure): void
|
||||
{
|
||||
if (! is_null(DescribeCall::describing())) {
|
||||
$filename = Backtrace::file();
|
||||
if (DescribeCall::describing() !== []) {
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
throw new AfterAllWithinDescribe($filename);
|
||||
}
|
||||
@@ -217,13 +203,13 @@ if (! function_exists('afterAll')) {
|
||||
|
||||
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
|
||||
*/
|
||||
function covers(array|string ...$classesOrFunctions): void
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
$filename = Backtrace::testFile();
|
||||
|
||||
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
||||
|
||||
@@ -232,7 +218,7 @@ if (! function_exists('covers')) {
|
||||
|
||||
/** @var MutationTestRunner $runner */
|
||||
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
||||
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
|
||||
/** @var ConfigurationRepository $configurationRepository */
|
||||
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
|
||||
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
|
||||
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
|
||||
@@ -243,3 +229,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::testFile();
|
||||
|
||||
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
|
||||
$beforeEachCall->group('__pest_mutate_only');
|
||||
|
||||
/** @var MutationTestRunner $runner */
|
||||
$runner = Container::getInstance()->get(MutationTestRunner::class);
|
||||
/** @var 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
+35
-11
@@ -27,15 +27,22 @@ use Whoops\Exception\Inspector;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Kernel
|
||||
final class Kernel
|
||||
{
|
||||
/**
|
||||
* Either the kernel is terminated or not.
|
||||
*/
|
||||
private bool $terminated = false;
|
||||
|
||||
/**
|
||||
* The Kernel bootstrappers.
|
||||
*
|
||||
* @var array<int, class-string>
|
||||
*/
|
||||
private const BOOTSTRAPPERS = [
|
||||
private const array BOOTSTRAPPERS = [
|
||||
Bootstrappers\BootOverrides::class,
|
||||
Bootstrappers\BootPhpUnitConfiguration::class,
|
||||
Plugins\Tia\Bootstrapper::class,
|
||||
Bootstrappers\BootSubscribers::class,
|
||||
Bootstrappers\BootFiles::class,
|
||||
Bootstrappers\BootView::class,
|
||||
@@ -43,15 +50,22 @@ final readonly class Kernel
|
||||
Bootstrappers\BootExcludeList::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The Kernel restarters — resolved and invoked from `bin/pest`
|
||||
* before any other Pest class is touched, so the list is exposed
|
||||
* on the Kernel rather than driven from `bin/pest` directly.
|
||||
*
|
||||
* @var array<int, class-string<Contracts\Restarter>>
|
||||
*/
|
||||
public const array RESTARTERS = [
|
||||
Restarters\XdebugRestarter::class,
|
||||
Restarters\PcovRestarter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new Kernel instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private Application $application,
|
||||
private OutputInterface $output,
|
||||
) {
|
||||
//
|
||||
}
|
||||
public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
|
||||
|
||||
/**
|
||||
* Boots the Kernel.
|
||||
@@ -71,7 +85,7 @@ final readonly class Kernel
|
||||
$output,
|
||||
);
|
||||
|
||||
register_shutdown_function(fn () => $kernel->shutdown());
|
||||
register_shutdown_function($kernel->shutdown(...));
|
||||
|
||||
foreach (self::BOOTSTRAPPERS as $bootstrapper) {
|
||||
$bootstrapper = Container::getInstance()->get($bootstrapper);
|
||||
@@ -112,9 +126,13 @@ final readonly class Kernel
|
||||
$configuration = Registry::get();
|
||||
$result = Facade::result();
|
||||
|
||||
return CallsAddsOutput::execute(
|
||||
$result = CallsAddsOutput::execute(
|
||||
Result::exitCode($configuration, $result),
|
||||
);
|
||||
|
||||
$this->terminate();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +140,12 @@ final readonly class Kernel
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
if ($this->terminated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->terminated = true;
|
||||
|
||||
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
|
||||
|
||||
assert($preBufferOutput instanceof KernelDump);
|
||||
@@ -139,7 +163,7 @@ final readonly class Kernel
|
||||
$this->terminate();
|
||||
|
||||
if (is_array($error = error_get_last())) {
|
||||
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
|
||||
if (! in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest;
|
||||
|
||||
use Laravel\Pao\Execution;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
@@ -28,6 +29,10 @@ final class KernelDump
|
||||
*/
|
||||
public function enable(): void
|
||||
{
|
||||
if (class_exists(Execution::class) && Execution::running()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ob_start(function (string $message): string {
|
||||
$this->buffer .= $message;
|
||||
|
||||
@@ -40,7 +45,7 @@ final class KernelDump
|
||||
*/
|
||||
public function disable(): void
|
||||
{
|
||||
@ob_clean(); // @phpstan-ignore-line
|
||||
@ob_clean();
|
||||
|
||||
if ($this->buffer !== '') {
|
||||
$this->flush();
|
||||
@@ -68,6 +73,10 @@ final class KernelDump
|
||||
|
||||
$type = 'INFO';
|
||||
|
||||
if (is_array($error = error_get_last()) && in_array($error['type'], [E_ERROR, E_COMPILE_ERROR, E_CORE_ERROR], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isInternalError($this->buffer)) {
|
||||
$type = 'ERROR';
|
||||
$this->buffer = str_replace(
|
||||
@@ -107,7 +116,6 @@ final class KernelDump
|
||||
*/
|
||||
private function isInternalError(string $output): bool
|
||||
{
|
||||
return str_contains($output, 'An error occurred inside PHPUnit.')
|
||||
|| str_contains($output, 'Fatal error');
|
||||
return str_contains($output, 'An error occurred inside PHPUnit.');
|
||||
}
|
||||
}
|
||||
|
||||
+28
-23
@@ -11,12 +11,10 @@ use Pest\Support\Str;
|
||||
use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||
use PHPUnit\Event\TestSuite\TestSuite;
|
||||
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
|
||||
use PHPUnit\Framework\Exception as FrameworkException;
|
||||
@@ -30,7 +28,7 @@ final readonly class Converter
|
||||
/**
|
||||
* The prefix for the test suite name.
|
||||
*/
|
||||
private const PREFIX = 'P\\';
|
||||
private const string PREFIX = 'P\\';
|
||||
|
||||
/**
|
||||
* The state generator.
|
||||
@@ -130,7 +128,7 @@ final readonly class Converter
|
||||
|
||||
// clean the paths of each frame.
|
||||
$frames = array_map(
|
||||
fn (string $frame): string => $this->toRelativePath($frame),
|
||||
$this->toRelativePath(...),
|
||||
$frames
|
||||
);
|
||||
|
||||
@@ -150,7 +148,7 @@ final readonly class Converter
|
||||
{
|
||||
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
|
||||
$firstTest = $this->getFirstTest($testSuite);
|
||||
if ($firstTest != null) {
|
||||
if ($firstTest instanceof TestMethod) {
|
||||
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
|
||||
}
|
||||
}
|
||||
@@ -178,7 +176,7 @@ final readonly class Converter
|
||||
public function getTestSuiteLocation(TestSuite $testSuite): ?string
|
||||
{
|
||||
$firstTest = $this->getFirstTest($testSuite);
|
||||
if ($firstTest == null) {
|
||||
if (! $firstTest instanceof TestMethod) {
|
||||
return null;
|
||||
}
|
||||
$path = $firstTest->testDox()->prettifiedClassName();
|
||||
@@ -251,22 +249,29 @@ final readonly class Converter
|
||||
...$result->testMarkedIncompleteEvents(),
|
||||
];
|
||||
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
$notPassedTests = [];
|
||||
|
||||
return $this->getTestCaseLocation($event->test());
|
||||
},
|
||||
$events
|
||||
)
|
||||
)
|
||||
);
|
||||
foreach ($events as $event) {
|
||||
if ($event instanceof AfterLastTestMethodErrored) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
continue;
|
||||
}
|
||||
if ($event instanceof AfterLastTestMethodFailed) {
|
||||
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
|
||||
continue;
|
||||
}
|
||||
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
|
||||
$notPassedTests[] = $event->testClassName();
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
|
||||
continue;
|
||||
}
|
||||
|
||||
$notPassedTests[] = $this->getTestCaseLocation($event->test());
|
||||
}
|
||||
|
||||
$numberOfPassedTests = $result->numberOfTestsRun()
|
||||
- count(array_unique($notPassedTests))
|
||||
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
|
||||
|
||||
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
if ($event->message() === '__TODO__') {
|
||||
return; // "todo" tests are reported in the summary, not as ignored tests...
|
||||
}
|
||||
|
||||
$this->logger()->testSkipped($event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ final class TeamCityLogger
|
||||
|
||||
public function testFinished(Finished $event): void
|
||||
{
|
||||
if (! $this->time instanceof \PHPUnit\Event\Telemetry\HRTime) {
|
||||
if (! $this->time instanceof HRTime) {
|
||||
throw ShouldNotHappen::fromMessage('Start time has not been set.');
|
||||
}
|
||||
|
||||
@@ -232,7 +232,6 @@ final class TeamCityLogger
|
||||
$reflector = new ReflectionClass($telemetry);
|
||||
|
||||
$property = $reflector->getProperty('current');
|
||||
$property->setAccessible(true);
|
||||
$snapshot = $property->getValue($telemetry);
|
||||
assert($snapshot instanceof Snapshot);
|
||||
|
||||
@@ -242,6 +241,12 @@ final class TeamCityLogger
|
||||
$telemetry->memoryUsageSinceStart(),
|
||||
$telemetry->durationSincePrevious(),
|
||||
$telemetry->memoryUsageSincePrevious(),
|
||||
$telemetry->userCpuTimeSinceStart(),
|
||||
$telemetry->systemCpuTimeSinceStart(),
|
||||
$telemetry->totalCpuTimeSinceStart(),
|
||||
$telemetry->userCpuTimeSincePrevious(),
|
||||
$telemetry->systemCpuTimeSincePrevious(),
|
||||
$telemetry->totalCpuTimeSincePrevious(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+190
-30
@@ -9,10 +9,12 @@ use Closure;
|
||||
use Countable;
|
||||
use DateTimeInterface;
|
||||
use Error;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use InvalidArgumentException;
|
||||
use JsonSerializable;
|
||||
use Pest\Exceptions\InvalidExpectationValue;
|
||||
use Pest\Matchers\Any;
|
||||
use Pest\Plugins\Snapshot;
|
||||
use Pest\Support\Arr;
|
||||
use Pest\Support\Exporter;
|
||||
use Pest\Support\NullClosure;
|
||||
@@ -183,7 +185,6 @@ final class Expectation
|
||||
{
|
||||
foreach ($needles as $needle) {
|
||||
if (is_string($this->value)) {
|
||||
// @phpstan-ignore-next-line
|
||||
Assert::assertStringContainsString((string) $needle, $this->value);
|
||||
} else {
|
||||
if (! is_iterable($this->value)) {
|
||||
@@ -782,15 +783,13 @@ final class Expectation
|
||||
foreach ($array as $key => $value) {
|
||||
Assert::assertArrayHasKey($key, $valueAsArray, $message);
|
||||
|
||||
if ($message === '') {
|
||||
$message = sprintf(
|
||||
'Failed asserting that an array has a key %s with the value %s.',
|
||||
$this->export($key),
|
||||
$this->export($valueAsArray[$key]),
|
||||
);
|
||||
}
|
||||
$assertMessage = $message !== '' ? $message : sprintf(
|
||||
'Failed asserting that an array has a key %s with the value %s.',
|
||||
$this->export($key),
|
||||
$this->export($valueAsArray[$key]),
|
||||
);
|
||||
|
||||
Assert::assertEquals($value, $valueAsArray[$key], $message);
|
||||
Assert::assertEquals($value, $valueAsArray[$key], $assertMessage);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -803,7 +802,7 @@ final class Expectation
|
||||
* @param iterable<string, mixed> $object
|
||||
* @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) {
|
||||
if (! is_object($this->value) && ! is_string($this->value)) {
|
||||
@@ -815,15 +814,13 @@ final class Expectation
|
||||
/* @phpstan-ignore-next-line */
|
||||
$propertyValue = $this->value->{$property};
|
||||
|
||||
if ($message === '') {
|
||||
$message = sprintf(
|
||||
'Failed asserting that an object has a property %s with the value %s.',
|
||||
$this->export($property),
|
||||
$this->export($propertyValue),
|
||||
);
|
||||
}
|
||||
$assertMessage = $message !== '' ? $message : sprintf(
|
||||
'Failed asserting that an object has a property %s with the value %s.',
|
||||
$this->export($property),
|
||||
$this->export($propertyValue),
|
||||
);
|
||||
|
||||
Assert::assertEquals($value, $propertyValue, $message);
|
||||
Assert::assertEquals($value, $propertyValue, $assertMessage);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -847,7 +844,7 @@ final class Expectation
|
||||
is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(),
|
||||
is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(),
|
||||
is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(),
|
||||
$this->value instanceof \Illuminate\Testing\TestResponse => $this->value->getContent(), // @phpstan-ignore-line
|
||||
$this->value instanceof TestResponse => $this->value->getContent(), // @phpstan-ignore-line
|
||||
is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||
$this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
|
||||
@@ -855,18 +852,31 @@ final class Expectation
|
||||
default => InvalidExpectationValue::expected('array|object|string'),
|
||||
};
|
||||
|
||||
if ($snapshots->has()) {
|
||||
[$filename, $content] = $snapshots->get();
|
||||
|
||||
Assert::assertSame(
|
||||
strtr($content, ["\r\n" => "\n", "\r" => "\n"]),
|
||||
strtr($string, ["\r\n" => "\n", "\r" => "\n"]),
|
||||
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
|
||||
);
|
||||
} else {
|
||||
if (! $snapshots->has()) {
|
||||
$filename = $snapshots->save($string);
|
||||
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
|
||||
} else {
|
||||
[$filename, $content] = $snapshots->get();
|
||||
|
||||
$normalizedContent = strtr($content, ["\r\n" => "\n", "\r" => "\n"]);
|
||||
$normalizedString = strtr($string, ["\r\n" => "\n", "\r" => "\n"]);
|
||||
|
||||
if (Snapshot::$updateSnapshots && $normalizedContent !== $normalizedString) {
|
||||
$snapshots->save($string);
|
||||
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot updated at [$filename]");
|
||||
} else {
|
||||
if (Snapshot::$updateSnapshots) {
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot unchanged at [$filename]");
|
||||
}
|
||||
|
||||
Assert::assertSame(
|
||||
$normalizedContent,
|
||||
$normalizedString,
|
||||
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -926,7 +936,7 @@ final class Expectation
|
||||
|
||||
if ($exception instanceof Closure) {
|
||||
$callback = $exception;
|
||||
$parameters = (new ReflectionFunction($exception))->getParameters();
|
||||
$parameters = new ReflectionFunction($exception)->getParameters();
|
||||
|
||||
if (count($parameters) !== 1) {
|
||||
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
|
||||
@@ -944,6 +954,7 @@ final class Expectation
|
||||
} catch (Throwable $e) {
|
||||
|
||||
if ($exception instanceof Throwable) {
|
||||
// @phpstan-ignore-next-line
|
||||
expect($e)
|
||||
->toBeInstanceOf($exception::class, $message)
|
||||
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
|
||||
@@ -988,7 +999,7 @@ final class Expectation
|
||||
*/
|
||||
private function export(mixed $value): string
|
||||
{
|
||||
if (! $this->exporter instanceof \Pest\Support\Exporter) {
|
||||
if (! $this->exporter instanceof Exporter) {
|
||||
$this->exporter = Exporter::default();
|
||||
}
|
||||
|
||||
@@ -1131,6 +1142,22 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a ULID.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeUlid(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue(Str::isUlid($this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is between 2 specified values
|
||||
*
|
||||
@@ -1144,6 +1171,22 @@ final class Expectation
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is an email address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeEmail(string $message = ''): self
|
||||
{
|
||||
if ($message === '') {
|
||||
$message = "Failed asserting that {$this->value} is an email address.";
|
||||
}
|
||||
|
||||
Assert::assertTrue(Str::isEmail((string) $this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a url
|
||||
*
|
||||
@@ -1159,4 +1202,121 @@ final class Expectation
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is an IP address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeIpAddress(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a MAC address.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeMacAddress(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a hostname.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeHostname(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a domain name.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeDomain(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
|
||||
&& str_contains($this->value, '.');
|
||||
|
||||
Assert::assertTrue($isValid, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a base64-encoded string.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeBase64(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
$decoded = base64_decode($this->value, true);
|
||||
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the value is a hexadecimal string.
|
||||
*
|
||||
* @return self<TValue>
|
||||
*/
|
||||
public function toBeHexadecimal(string $message = ''): self
|
||||
{
|
||||
if (! is_string($this->value)) {
|
||||
InvalidExpectationValue::expected('string');
|
||||
}
|
||||
|
||||
Assert::assertTrue(ctype_xdigit($this->value), $message);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\PHPStan;
|
||||
|
||||
use Pest\Expectations\HigherOrderExpectation;
|
||||
use PhpParser\Node\Expr;
|
||||
use PhpParser\Node\Expr\PropertyFetch;
|
||||
use PhpParser\Node\Identifier;
|
||||
use PHPStan\Analyser\Scope;
|
||||
use PHPStan\Reflection\ReflectionProvider;
|
||||
use PHPStan\Type\ExpressionTypeResolverExtension;
|
||||
use PHPStan\Type\ObjectType;
|
||||
use PHPStan\Type\Type;
|
||||
|
||||
/**
|
||||
* Prevents native declared properties of HigherOrderExpectation (like $original,
|
||||
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
|
||||
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
|
||||
*
|
||||
* This extension must be registered BEFORE the pest-plugin-phpstan HigherOrderExpectationTypeExtension.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class HigherOrderExpectationTypeExtension implements ExpressionTypeResolverExtension
|
||||
{
|
||||
public function __construct(
|
||||
private ReflectionProvider $reflectionProvider,
|
||||
) {}
|
||||
|
||||
public function getType(Expr $expr, Scope $scope): ?Type
|
||||
{
|
||||
if (! $expr instanceof PropertyFetch || ! $expr->name instanceof Identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$varType = $scope->getType($expr->var);
|
||||
|
||||
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->reflectionProvider->hasClass(HigherOrderExpectation::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$propertyName = $expr->name->name;
|
||||
$classReflection = $this->reflectionProvider->getClass(HigherOrderExpectation::class);
|
||||
|
||||
if (! $classReflection->hasNativeProperty($propertyName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $varType->getProperty($propertyName, $scope)->getReadableType();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -46,7 +46,7 @@ final readonly class Panic
|
||||
{
|
||||
try {
|
||||
$output = Container::getInstance()->get(OutputInterface::class);
|
||||
} catch (Throwable) { // @phpstan-ignore-line
|
||||
} catch (Throwable) {
|
||||
$output = new ConsoleOutput;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Pest\PendingCalls;
|
||||
|
||||
use Closure;
|
||||
use Pest\PendingCalls\Concerns\Describable;
|
||||
use Pest\Support\Arr;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\HigherOrderMessageCollection;
|
||||
@@ -54,8 +55,8 @@ final class AfterEachCall
|
||||
$proxies = $this->proxies;
|
||||
|
||||
$afterEachTestCase = ChainableClosure::boundWhen(
|
||||
fn (): bool => is_null($describing) || $this->__describing === $describing,
|
||||
ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line
|
||||
fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
|
||||
ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class),
|
||||
)->bindTo($this, self::class);
|
||||
|
||||
assert($afterEachTestCase instanceof Closure);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Pest\PendingCalls;
|
||||
use Closure;
|
||||
use Pest\Exceptions\AfterBeforeTestFunction;
|
||||
use Pest\PendingCalls\Concerns\Describable;
|
||||
use Pest\Support\Arr;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\HigherOrderMessageCollection;
|
||||
@@ -63,12 +64,12 @@ final class BeforeEachCall
|
||||
|
||||
$beforeEachTestCall = function (TestCall $testCall) use ($describing): void {
|
||||
|
||||
if ($this->describing !== null) {
|
||||
if ($describing !== $this->describing) {
|
||||
if ($this->describing !== []) {
|
||||
if (Arr::last($describing) !== Arr::last($this->describing)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($describing !== $testCall->describing) {
|
||||
if (! in_array(Arr::last($describing), $testCall->describing, true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -77,8 +78,8 @@ final class BeforeEachCall
|
||||
};
|
||||
|
||||
$beforeEachTestCase = ChainableClosure::boundWhen(
|
||||
fn (): bool => is_null($describing) || $this->__describing === $describing,
|
||||
ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class), // @phpstan-ignore-line
|
||||
fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
|
||||
ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class),
|
||||
)->bindTo($this, self::class);
|
||||
|
||||
assert($beforeEachTestCase instanceof Closure);
|
||||
@@ -96,7 +97,7 @@ final class BeforeEachCall
|
||||
*/
|
||||
public function after(Closure $closure): self
|
||||
{
|
||||
if ($this->describing === null) {
|
||||
if ($this->describing === []) {
|
||||
throw new AfterBeforeTestFunction($this->filename);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\PendingCalls\Concerns;
|
||||
|
||||
use Pest\Support\Description;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@@ -11,11 +13,15 @@ trait Describable
|
||||
{
|
||||
/**
|
||||
* Note: this is property is not used; however, it gets added automatically by rector php.
|
||||
*
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
public string $__describing;
|
||||
public array $__describing;
|
||||
|
||||
/**
|
||||
* The describing of the test case.
|
||||
*
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
public ?string $describing = null;
|
||||
public array $describing = [];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\PendingCalls;
|
||||
|
||||
use Closure;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\Description;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
@@ -15,8 +15,10 @@ final class DescribeCall
|
||||
{
|
||||
/**
|
||||
* The current describe call.
|
||||
*
|
||||
* @var array<int, Description>
|
||||
*/
|
||||
private static ?string $describing = null;
|
||||
private static array $describing = [];
|
||||
|
||||
/**
|
||||
* The describe "before each" call.
|
||||
@@ -29,7 +31,7 @@ final class DescribeCall
|
||||
public function __construct(
|
||||
public readonly TestSuite $testSuite,
|
||||
public readonly string $filename,
|
||||
public readonly string $description,
|
||||
public readonly Description $description,
|
||||
public readonly Closure $tests
|
||||
) {
|
||||
//
|
||||
@@ -37,8 +39,10 @@ final class DescribeCall
|
||||
|
||||
/**
|
||||
* What is the current describing.
|
||||
*
|
||||
* @return array<int, Description>
|
||||
*/
|
||||
public static function describing(): ?string
|
||||
public static function describing(): array
|
||||
{
|
||||
return self::$describing;
|
||||
}
|
||||
@@ -48,14 +52,18 @@ final class DescribeCall
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
unset($this->currentBeforeEachCall);
|
||||
// Ensure BeforeEachCall destructs before creating tests
|
||||
// by moving to local scope and clearing the reference
|
||||
$beforeEach = $this->currentBeforeEachCall;
|
||||
$this->currentBeforeEachCall = null;
|
||||
unset($beforeEach); // Trigger destructor immediately
|
||||
|
||||
self::$describing = $this->description;
|
||||
self::$describing[] = $this->description;
|
||||
|
||||
try {
|
||||
($this->tests)();
|
||||
} finally {
|
||||
self::$describing = null;
|
||||
array_pop(self::$describing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,15 +74,16 @@ final class DescribeCall
|
||||
*/
|
||||
public function __call(string $name, array $arguments): self
|
||||
{
|
||||
$filename = Backtrace::file();
|
||||
if (! $this->currentBeforeEachCall instanceof BeforeEachCall) {
|
||||
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $this->filename);
|
||||
|
||||
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) {
|
||||
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
|
||||
|
||||
$this->currentBeforeEachCall->describing = $this->description;
|
||||
$this->currentBeforeEachCall->describing = array_merge(
|
||||
DescribeCall::describing(),
|
||||
[$this->description]
|
||||
);
|
||||
}
|
||||
|
||||
$this->currentBeforeEachCall->{$name}(...$arguments); // @phpstan-ignore-line
|
||||
$this->currentBeforeEachCall->{$name}(...$arguments);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
+115
-25
@@ -5,12 +5,14 @@ declare(strict_types=1);
|
||||
namespace Pest\PendingCalls;
|
||||
|
||||
use Closure;
|
||||
use Pest\Concerns\Testable;
|
||||
use Pest\Exceptions\InvalidArgumentException;
|
||||
use Pest\Exceptions\TestDescriptionMissing;
|
||||
use Pest\Factories\Attribute;
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
use Pest\Mutate\Repositories\ConfigurationRepository;
|
||||
use Pest\PendingCalls\Concerns\Describable;
|
||||
use Pest\Plugins\Environment;
|
||||
use Pest\Plugins\Only;
|
||||
use Pest\Support\Backtrace;
|
||||
use Pest\Support\Container;
|
||||
@@ -20,14 +22,18 @@ use Pest\Support\NullClosure;
|
||||
use Pest\Support\Str;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\CoversFunction;
|
||||
use PHPUnit\Framework\Attributes\CoversTrait;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @mixin HigherOrderCallables|TestCase
|
||||
* @mixin HigherOrderCallables|TestCase|Testable
|
||||
*/
|
||||
final class TestCall
|
||||
final class TestCall // @phpstan-ignore-line
|
||||
{
|
||||
use Describable;
|
||||
|
||||
@@ -75,7 +81,7 @@ final class TestCall
|
||||
throw new TestDescriptionMissing($this->filename);
|
||||
}
|
||||
|
||||
$description = is_null($this->describing)
|
||||
$description = $this->describing === []
|
||||
? $this->description
|
||||
: Str::describe($this->describing, $this->description);
|
||||
|
||||
@@ -177,10 +183,9 @@ final class TestCall
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the current test multiple times with
|
||||
* each item of the given `iterable`.
|
||||
* Runs the current test multiple times with 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
|
||||
{
|
||||
@@ -210,7 +215,7 @@ final class TestCall
|
||||
{
|
||||
foreach ($groups as $group) {
|
||||
$this->testCaseMethod->attributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\Group::class,
|
||||
Group::class,
|
||||
[$group],
|
||||
);
|
||||
}
|
||||
@@ -223,7 +228,7 @@ final class TestCall
|
||||
*/
|
||||
public function only(): self
|
||||
{
|
||||
Only::enable($this, ...func_get_args()); // @phpstan-ignore-line
|
||||
Only::enable($this, ...func_get_args());
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -314,6 +319,61 @@ final class TestCall
|
||||
: $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.
|
||||
*/
|
||||
@@ -352,14 +412,28 @@ final class TestCall
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the test as flaky, retrying it up to the given number of times.
|
||||
*/
|
||||
public function flaky(int $tries = 3): self
|
||||
{
|
||||
if ($tries < 1) {
|
||||
throw new InvalidArgumentException('The number of tries must be greater than 0.');
|
||||
}
|
||||
|
||||
$this->testCaseMethod->flakyTries = $tries;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the test as "todo".
|
||||
*/
|
||||
public function todo(// @phpstan-ignore-line
|
||||
array|string|null $note = null,
|
||||
array|string|null $assignee = null,
|
||||
array|string|null $issue = null,
|
||||
array|string|null $pr = null,
|
||||
array|string|int|null $issue = null,
|
||||
array|string|int|null $pr = null,
|
||||
): self {
|
||||
$this->skip('__TODO__');
|
||||
|
||||
@@ -390,8 +464,8 @@ final class TestCall
|
||||
public function wip(// @phpstan-ignore-line
|
||||
array|string|null $note = null,
|
||||
array|string|null $assignee = null,
|
||||
array|string|null $issue = null,
|
||||
array|string|null $pr = null,
|
||||
array|string|int|null $issue = null,
|
||||
array|string|int|null $pr = null,
|
||||
): self {
|
||||
if ($issue !== null) {
|
||||
$this->issue($issue);
|
||||
@@ -418,8 +492,8 @@ final class TestCall
|
||||
public function done(// @phpstan-ignore-line
|
||||
array|string|null $note = null,
|
||||
array|string|null $assignee = null,
|
||||
array|string|null $issue = null,
|
||||
array|string|null $pr = null,
|
||||
array|string|int|null $issue = null,
|
||||
array|string|int|null $pr = null,
|
||||
): self {
|
||||
if ($issue !== null) {
|
||||
$this->issue($issue);
|
||||
@@ -548,7 +622,7 @@ final class TestCall
|
||||
{
|
||||
foreach ($classes as $class) {
|
||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversClass::class,
|
||||
CoversClass::class,
|
||||
[$class],
|
||||
);
|
||||
}
|
||||
@@ -571,7 +645,7 @@ final class TestCall
|
||||
{
|
||||
foreach ($traits as $trait) {
|
||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversTrait::class,
|
||||
CoversTrait::class,
|
||||
[$trait],
|
||||
);
|
||||
}
|
||||
@@ -594,7 +668,7 @@ final class TestCall
|
||||
{
|
||||
foreach ($functions as $function) {
|
||||
$this->testCaseFactoryAttributes[] = new Attribute(
|
||||
\PHPUnit\Framework\Attributes\CoversFunction::class,
|
||||
CoversFunction::class,
|
||||
[$function],
|
||||
);
|
||||
}
|
||||
@@ -603,18 +677,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(
|
||||
\PHPUnit\Framework\Attributes\CoversNothing::class,
|
||||
[],
|
||||
);
|
||||
assert($classes !== []);
|
||||
|
||||
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,
|
||||
* and its purpose is simply to check whether the given code can
|
||||
@@ -682,7 +767,7 @@ final class TestCall
|
||||
throw new TestDescriptionMissing($this->filename);
|
||||
}
|
||||
|
||||
if (! is_null($this->describing)) {
|
||||
if ($this->describing !== []) {
|
||||
$this->testCaseMethod->describing = $this->describing;
|
||||
$this->testCaseMethod->description = Str::describe($this->describing, $this->description);
|
||||
} else {
|
||||
@@ -692,7 +777,12 @@ final class TestCall
|
||||
$this->testSuite->tests->set($this->testCaseMethod);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,7 @@ final class UsesCall
|
||||
$this->targets = [$filename];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `pest()->printer()->compact()` instead.
|
||||
*/
|
||||
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
|
||||
public function compact(): self
|
||||
{
|
||||
DefaultPrinter::compact(true);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '3.0.7';
|
||||
return '5.0.3';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@@ -21,7 +21,7 @@ final class Cache implements HandlesArguments
|
||||
/**
|
||||
* The temporary folder.
|
||||
*/
|
||||
private const TEMPORARY_FOLDER = __DIR__
|
||||
private const string TEMPORARY_FOLDER = __DIR__
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'..'
|
||||
.DIRECTORY_SEPARATOR
|
||||
@@ -50,7 +50,7 @@ final class Cache implements HandlesArguments
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->hasArgument('--parallel', $arguments)) {
|
||||
if (! $this->hasArgument('--parallel', $arguments) && ! $this->hasArgument('--do-not-cache-result', $arguments) && ! $this->hasArgument('--cache-result', $arguments)) {
|
||||
return $this->pushArgument('--cache-result', $arguments);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,10 +50,40 @@ trait HandleArguments
|
||||
*/
|
||||
public function popArgument(string $argument, array $arguments): array
|
||||
{
|
||||
$arguments = array_flip($arguments);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
|
||||
unset($arguments[$argument]);
|
||||
while ($key !== false) {
|
||||
unset($arguments[$key]);
|
||||
$key = array_search($argument, $arguments, true);
|
||||
}
|
||||
|
||||
return array_values(array_flip($arguments));
|
||||
return array_values($arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops the given argument and its value from the arguments, returning the value.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
public function popArgumentValue(string $argument, array &$arguments): ?string
|
||||
{
|
||||
foreach ($arguments as $key => $value) {
|
||||
if (str_contains($value, "$argument=")) {
|
||||
unset($arguments[$key]);
|
||||
$arguments = array_values($arguments);
|
||||
|
||||
return substr($value, strlen($argument) + 1);
|
||||
}
|
||||
|
||||
if ($value === $argument && isset($arguments[$key + 1])) {
|
||||
$result = $arguments[$key + 1];
|
||||
unset($arguments[$key], $arguments[$key + 1]);
|
||||
$arguments = array_values($arguments);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ final class Configuration implements HandlesArguments, Terminable
|
||||
/**
|
||||
* The base PHPUnit file.
|
||||
*/
|
||||
public const BASE_PHPUNIT_FILE = __DIR__
|
||||
public const string BASE_PHPUNIT_FILE = __DIR__
|
||||
.DIRECTORY_SEPARATOR
|
||||
.'..'
|
||||
.DIRECTORY_SEPARATOR
|
||||
@@ -34,7 +34,7 @@ final class Configuration implements HandlesArguments, Terminable
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+65
-15
@@ -17,26 +17,41 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class Coverage implements AddsOutput, HandlesArguments
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const COVERAGE_OPTION = 'coverage';
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const MIN_OPTION = 'min';
|
||||
private const string COVERAGE_OPTION = 'coverage';
|
||||
|
||||
private const string MIN_OPTION = 'min';
|
||||
|
||||
private const string EXACTLY_OPTION = 'exactly';
|
||||
|
||||
private const string ONLY_COVERED_OPTION = 'only-covered';
|
||||
|
||||
/**
|
||||
* Whether it should show the coverage or not.
|
||||
*/
|
||||
public bool $coverage = false;
|
||||
|
||||
/**
|
||||
* Whether it should show the coverage or not.
|
||||
*/
|
||||
public bool $compact = false;
|
||||
|
||||
/**
|
||||
* The minimum coverage.
|
||||
*/
|
||||
public float $coverageMin = 0.0;
|
||||
|
||||
/**
|
||||
* The exactly coverage.
|
||||
*/
|
||||
public ?float $coverageExactly = null;
|
||||
|
||||
/**
|
||||
* Whether it should show only covered files.
|
||||
*/
|
||||
public bool $showOnlyCovered = false;
|
||||
|
||||
/**
|
||||
* Creates a new Plugin instance.
|
||||
*/
|
||||
@@ -51,7 +66,7 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
public function handleArguments(array $originals): array
|
||||
{
|
||||
$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, self::ONLY_COVERED_OPTION] as $option) {
|
||||
if ($original === sprintf('--%s', $option)) {
|
||||
return true;
|
||||
}
|
||||
@@ -64,15 +79,15 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
return false;
|
||||
}))];
|
||||
|
||||
$originals = array_flip($originals);
|
||||
foreach ($arguments as $argument) {
|
||||
unset($originals[$argument]);
|
||||
$originals = $this->popArgument($argument, $originals);
|
||||
}
|
||||
$originals = array_flip($originals);
|
||||
|
||||
$inputs = [];
|
||||
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
|
||||
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
|
||||
$inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED);
|
||||
$inputs[] = new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE);
|
||||
|
||||
$input = new ArgvInput($arguments, new InputDefinition($inputs));
|
||||
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
|
||||
@@ -106,6 +121,21 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
$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 ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) {
|
||||
$this->showOnlyCovered = true;
|
||||
}
|
||||
|
||||
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
|
||||
$this->compact = true;
|
||||
}
|
||||
|
||||
return $originals;
|
||||
}
|
||||
|
||||
@@ -126,11 +156,23 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$coverage = \Pest\Support\Coverage::report($this->output);
|
||||
|
||||
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered);
|
||||
$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(
|
||||
"\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),
|
||||
@@ -143,4 +185,12 @@ final class Coverage implements AddsOutput, HandlesArguments
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the comparable coverage to a percentage with one decimal.
|
||||
*/
|
||||
private function computeComparableCoverage(float $coverage): float
|
||||
{
|
||||
return floor($coverage * 10) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ final class Environment implements HandlesArguments
|
||||
/**
|
||||
* The continuous integration environment.
|
||||
*/
|
||||
public const CI = 'ci';
|
||||
public const string CI = 'ci';
|
||||
|
||||
/**
|
||||
* The local environment.
|
||||
*/
|
||||
public const LOCAL = 'local';
|
||||
public const string LOCAL = 'local';
|
||||
|
||||
/**
|
||||
* The current environment.
|
||||
|
||||
@@ -99,6 +99,7 @@ final readonly class Help implements HandlesArguments
|
||||
{
|
||||
$helpReflection = new PHPUnitHelp;
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$content = (fn (): array => $this->elements())->call($helpReflection);
|
||||
|
||||
$content['Configuration'] = [...[[
|
||||
@@ -106,6 +107,48 @@ final readonly class Help implements HandlesArguments
|
||||
'desc' => 'Initialise a standard Pest configuration',
|
||||
]], ...$content['Configuration']];
|
||||
|
||||
$content['AI'] = [
|
||||
[
|
||||
'arg' => '--ai',
|
||||
'desc' => 'Run a code snippet as a fully scaffolded test for AI verification',
|
||||
],
|
||||
];
|
||||
|
||||
$content['Tia'] = [
|
||||
[
|
||||
'arg' => '--tia',
|
||||
'desc' => 'Re-run only the tests affected by your changes, replaying the rest from cache',
|
||||
],
|
||||
[
|
||||
'arg' => '--no-tia',
|
||||
'desc' => 'Disable test impact analysis for this run',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --fresh',
|
||||
'desc' => 'Discard the recorded dependency graph and record it again',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --filtered',
|
||||
'desc' => 'Narrow the run to the affected test files only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --locally',
|
||||
'desc' => 'Enable test impact analysis on local machines only',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --baselined',
|
||||
'desc' => 'Fetch the shared dependency graph recorded by the CI baseline',
|
||||
],
|
||||
[
|
||||
'arg' => '--tia --refetch',
|
||||
'desc' => 'Force a fresh fetch of the shared dependency graph',
|
||||
],
|
||||
[
|
||||
'arg' => '--baseline',
|
||||
'desc' => 'Output to standard output the test impact analysis storage directory',
|
||||
],
|
||||
];
|
||||
|
||||
$content['Execution'] = [...[
|
||||
[
|
||||
'arg' => '--parallel',
|
||||
@@ -115,6 +158,10 @@ final readonly class Help implements HandlesArguments
|
||||
'arg' => '--update-snapshots',
|
||||
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
|
||||
],
|
||||
[
|
||||
'arg' => '--update-shards',
|
||||
'desc' => 'Update shards.json with test timing data for time-balanced sharding',
|
||||
],
|
||||
], ...$content['Execution']];
|
||||
|
||||
$content['Selection'] = [[
|
||||
@@ -141,6 +188,12 @@ final readonly class Help implements HandlesArguments
|
||||
], [
|
||||
'arg' => '--retry',
|
||||
'desc' => 'Run non-passing tests first and stop execution upon first error or failure',
|
||||
], [
|
||||
'arg' => '--dirty',
|
||||
'desc' => 'Only run tests that have uncommitted changes according to Git',
|
||||
], [
|
||||
'arg' => '--flaky',
|
||||
'desc' => 'Output to standard output tests marked as flaky',
|
||||
], ...$content['Selection']];
|
||||
|
||||
$content['Reporting'] = [...$content['Reporting'], ...[
|
||||
@@ -156,6 +209,12 @@ final readonly class Help implements HandlesArguments
|
||||
], [
|
||||
'arg' => '--coverage --min',
|
||||
'desc' => 'Set the minimum required coverage percentage, and fail if not met',
|
||||
], [
|
||||
'arg' => '--coverage --exactly',
|
||||
'desc' => 'Set the exact required coverage percentage, and fail if not met',
|
||||
], [
|
||||
'arg' => '--coverage --only-covered',
|
||||
'desc' => 'Hide files with 0% coverage from the code coverage report',
|
||||
], ...$content['Code Coverage']];
|
||||
|
||||
$content['Mutation Testing'] = [[
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user