diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d89a73e..73ab63e5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,12 +15,9 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, macos-latest, windows-latest] - symfony: ['7.0.2'] + symfony: ['7.0'] php: ['8.2', '8.3'] dependency_version: [prefer-lowest, prefer-stable] - exclude: - - php: '8.1' - symfony: '7.0.2' name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} @@ -41,7 +38,7 @@ 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 }}" + run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:~${{ matrix.symfony }}" - name: Unit Tests run: composer test:unit diff --git a/composer.json b/composer.json index bac5acdc..94ebf1d8 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ ], "require": { "php": "^8.2.0", - "nunomaduro/collision": "^8.0.1", + "nunomaduro/collision": "^8.1.0", "nunomaduro/termwind": "^2.0.0", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.0.0", @@ -105,7 +105,8 @@ "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", - "Pest\\Plugins\\Parallel" + "Pest\\Plugins\\Parallel", + "Pest\\Plugins\\JUnit" ] }, "phpstan": { diff --git a/overrides/Logging/JUnit/JunitXmlLogger.php b/overrides/Logging/JUnit/JunitXmlLogger.php new file mode 100644 index 00000000..bc0e0953 --- /dev/null +++ b/overrides/Logging/JUnit/JunitXmlLogger.php @@ -0,0 +1,16 @@ +className(), self::PREFIX); + } + /** * Gets the test suite location. */ diff --git a/src/Logging/JUnit/JUnitLogger.php b/src/Logging/JUnit/JUnitLogger.php new file mode 100644 index 00000000..ba71f88f --- /dev/null +++ b/src/Logging/JUnit/JUnitLogger.php @@ -0,0 +1,384 @@ + + */ + private array $testSuiteTests = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteAssertions = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteErrors = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteFailures = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteSkipped = [0]; + + /** + * @psalm-var array + */ + private array $testSuiteTimes = [0]; + + private int $testSuiteLevel = 0; + + private ?DOMElement $currentTestCase = null; + + private ?HRTime $time = null; + + private bool $prepared = false; + + /** + * @throws EventFacadeIsSealedException + * @throws UnknownSubscriberTypeException + */ + public function __construct( + private readonly Printer $printer, + private readonly Converter $converter, + ) { + $this->registerSubscribers(); + $this->createDocument(); + } + + public function flush(): void + { + $this->printer->print((string) $this->document->saveXML()); + + $this->printer->flush(); + } + + public function testSuiteStarted(Started $event): void + { + $testSuite = $this->document->createElement('testsuite'); + $testSuite->setAttribute('name', $this->converter->getTestSuiteName($event->testSuite())); + + if ($event->testSuite()->isForTestClass()) { + $testSuite->setAttribute('file', $this->converter->getTestSuiteLocation($event->testSuite()) ?? ''); + } + + if ($this->testSuiteLevel > 0) { + $this->testSuites[$this->testSuiteLevel]->appendChild($testSuite); + } else { + $this->root->appendChild($testSuite); + } + + $this->testSuiteLevel++; + $this->testSuites[$this->testSuiteLevel] = $testSuite; + $this->testSuiteTests[$this->testSuiteLevel] = 0; + $this->testSuiteAssertions[$this->testSuiteLevel] = 0; + $this->testSuiteErrors[$this->testSuiteLevel] = 0; + $this->testSuiteFailures[$this->testSuiteLevel] = 0; + $this->testSuiteSkipped[$this->testSuiteLevel] = 0; + $this->testSuiteTimes[$this->testSuiteLevel] = 0; + } + + public function testSuiteFinished(): void + { + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'tests', + (string) $this->testSuiteTests[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'assertions', + (string) $this->testSuiteAssertions[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'errors', + (string) $this->testSuiteErrors[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'failures', + (string) $this->testSuiteFailures[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'skipped', + (string) $this->testSuiteSkipped[$this->testSuiteLevel], + ); + + $this->testSuites[$this->testSuiteLevel]->setAttribute( + 'time', + sprintf('%F', $this->testSuiteTimes[$this->testSuiteLevel]), + ); + + if ($this->testSuiteLevel > 1) { + $this->testSuiteTests[$this->testSuiteLevel - 1] += $this->testSuiteTests[$this->testSuiteLevel]; + $this->testSuiteAssertions[$this->testSuiteLevel - 1] += $this->testSuiteAssertions[$this->testSuiteLevel]; + $this->testSuiteErrors[$this->testSuiteLevel - 1] += $this->testSuiteErrors[$this->testSuiteLevel]; + $this->testSuiteFailures[$this->testSuiteLevel - 1] += $this->testSuiteFailures[$this->testSuiteLevel]; + $this->testSuiteSkipped[$this->testSuiteLevel - 1] += $this->testSuiteSkipped[$this->testSuiteLevel]; + $this->testSuiteTimes[$this->testSuiteLevel - 1] += $this->testSuiteTimes[$this->testSuiteLevel]; + } + + $this->testSuiteLevel--; + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testPrepared(Prepared $event): void + { + $this->createTestCase($event); + $this->prepared = true; + } + + /** + * @throws InvalidArgumentException + */ + public function testFinished(Finished $event): void + { + $this->handleFinish($event->telemetryInfo(), $event->numberOfAssertionsPerformed()); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testMarkedIncomplete(MarkedIncomplete $event): void + { + $this->handleIncompleteOrSkipped($event); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testSkipped(Skipped $event): void + { + $this->handleIncompleteOrSkipped($event); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testErrored(Errored $event): void + { + $this->handleFault($event, 'error'); + + $this->testSuiteErrors[$this->testSuiteLevel]++; + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + public function testFailed(Failed $event): void + { + $this->handleFault($event, 'failure'); + + $this->testSuiteFailures[$this->testSuiteLevel]++; + } + + /** + * @throws InvalidArgumentException + */ + private function handleFinish(Info $telemetryInfo, int $numberOfAssertionsPerformed): void + { + assert($this->currentTestCase instanceof \DOMElement); + assert($this->time instanceof \PHPUnit\Event\Telemetry\HRTime); + + $time = $telemetryInfo->time()->duration($this->time)->asFloat(); + + $this->testSuiteAssertions[$this->testSuiteLevel] += $numberOfAssertionsPerformed; + + $this->currentTestCase->setAttribute( + 'assertions', + (string) $numberOfAssertionsPerformed, + ); + + $this->currentTestCase->setAttribute( + 'time', + sprintf('%F', $time), + ); + + $this->testSuites[$this->testSuiteLevel]->appendChild( + $this->currentTestCase, + ); + + $this->testSuiteTests[$this->testSuiteLevel]++; + $this->testSuiteTimes[$this->testSuiteLevel] += $time; + + $this->currentTestCase = null; + $this->time = null; + $this->prepared = false; + } + + /** + * @throws EventFacadeIsSealedException + * @throws UnknownSubscriberTypeException + */ + private function registerSubscribers(): void + { + $subscribers = [ + new TestSuiteStartedSubscriber($this), + new TestSuiteFinishedSubscriber($this), + new TestPreparedSubscriber($this), + new TestFinishedSubscriber($this), + new TestErroredSubscriber($this), + new TestFailedSubscriber($this), + new TestMarkedIncompleteSubscriber($this), + new TestSkippedSubscriber($this), + new TestRunnerExecutionFinishedSubscriber($this), + ]; + + Facade::instance()->registerSubscribers(...$subscribers); + } + + private function createDocument(): void + { + $this->document = new DOMDocument('1.0', 'UTF-8'); + $this->document->formatOutput = true; + + $this->root = $this->document->createElement('testsuites'); + $this->document->appendChild($this->root); + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + private function handleFault(Errored|Failed $event, string $type): void + { + if (! $this->prepared) { + $this->createTestCase($event); + } + + assert($this->currentTestCase instanceof \DOMElement); + + $throwable = $event->throwable(); + + $testName = $this->converter->getTestCaseMethodName($event->test()); + $message = $this->converter->getExceptionMessage($throwable); + $details = $this->converter->getExceptionDetails($throwable); + + $buffer = $testName; + $buffer .= trim( + $message.PHP_EOL. + $details, + ); + + $fault = $this->document->createElement( + $type, + Xml::prepareString($buffer), + ); + + $fault->setAttribute('type', $throwable->className()); + + $this->currentTestCase->appendChild($fault); + + if (! $this->prepared) { + $this->handleFinish($event->telemetryInfo(), 0); + } + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + */ + private function handleIncompleteOrSkipped(MarkedIncomplete|Skipped $event): void + { + if (! $this->prepared) { + $this->createTestCase($event); + } + + assert($this->currentTestCase instanceof \DOMElement); + + $skipped = $this->document->createElement('skipped'); + + $this->currentTestCase->appendChild($skipped); + + $this->testSuiteSkipped[$this->testSuiteLevel]++; + + if (! $this->prepared) { + $this->handleFinish($event->telemetryInfo(), 0); + } + } + + /** + * @throws InvalidArgumentException + * @throws NoDataSetFromDataProviderException + * + * @psalm-assert !null $this->currentTestCase + */ + private function createTestCase(Errored|Failed|MarkedIncomplete|Prepared|Skipped $event): void + { + $testCase = $this->document->createElement('testcase'); + + $test = $event->test(); + $file = $this->converter->getTestCaseLocation($test); + + $testCase->setAttribute('name', $this->converter->getTestCaseMethodName($test)); + $testCase->setAttribute('file', $file); + + if ($test->isTestMethod()) { + $className = $this->converter->getTrimmedTestClassName($test); + $testCase->setAttribute('class', $className); + $testCase->setAttribute('classname', str_replace('\\', '.', $className)); + } + + $this->currentTestCase = $testCase; + $this->time = $event->telemetryInfo()->time(); + } +} diff --git a/src/Logging/JUnit/Subscriber/Subscriber.php b/src/Logging/JUnit/Subscriber/Subscriber.php new file mode 100644 index 00000000..7ffa1f34 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/Subscriber.php @@ -0,0 +1,28 @@ +logger; + } +} diff --git a/src/Logging/JUnit/Subscriber/TestErroredSubscriber.php b/src/Logging/JUnit/Subscriber/TestErroredSubscriber.php new file mode 100644 index 00000000..87172774 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestErroredSubscriber.php @@ -0,0 +1,19 @@ +logger()->testErrored($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestFailedSubscriber.php b/src/Logging/JUnit/Subscriber/TestFailedSubscriber.php new file mode 100644 index 00000000..d0f6c1ea --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestFailedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testFailed($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php b/src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php new file mode 100644 index 00000000..4c1e937d --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testFinished($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php b/src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php new file mode 100644 index 00000000..4ed5d693 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestMarkedIncompleteSubscriber.php @@ -0,0 +1,19 @@ +logger()->testMarkedIncomplete($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php b/src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php new file mode 100644 index 00000000..f9841361 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestPreparedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testPrepared($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php b/src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php new file mode 100644 index 00000000..8dcf762f --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestRunnerExecutionFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->flush(); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php b/src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php new file mode 100644 index 00000000..afa764ad --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestSkippedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSkipped($event); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php b/src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php new file mode 100644 index 00000000..9ed15c15 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestSuiteFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSuiteFinished(); + } +} diff --git a/src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php b/src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php new file mode 100644 index 00000000..26f80239 --- /dev/null +++ b/src/Logging/JUnit/Subscriber/TestSuiteStartedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSuiteStarted($event); + } +} diff --git a/src/Logging/TeamCity/ServiceMessage.php b/src/Logging/TeamCity/ServiceMessage.php index ace7f40b..ca16dd80 100644 --- a/src/Logging/TeamCity/ServiceMessage.php +++ b/src/Logging/TeamCity/ServiceMessage.php @@ -63,7 +63,7 @@ final class ServiceMessage } /** - * @param int $duration in milliseconds + * @param int $duration in milliseconds */ public static function testFinished(string $name, int $duration): self { diff --git a/src/Logging/TeamCity/TeamCityLogger.php b/src/Logging/TeamCity/TeamCityLogger.php index 92d35daf..072bd608 100644 --- a/src/Logging/TeamCity/TeamCityLogger.php +++ b/src/Logging/TeamCity/TeamCityLogger.php @@ -6,6 +6,7 @@ namespace Pest\Logging\TeamCity; use NunoMaduro\Collision\Adapters\Phpunit\Style; use Pest\Exceptions\ShouldNotHappen; +use Pest\Logging\Converter; use Pest\Logging\TeamCity\Subscriber\TestConsideredRiskySubscriber; use Pest\Logging\TeamCity\Subscriber\TestErroredSubscriber; use Pest\Logging\TeamCity\Subscriber\TestExecutionFinishedSubscriber; diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php index 9b1e07a8..0d0b4d92 100644 --- a/src/Mixins/Expectation.php +++ b/src/Mixins/Expectation.php @@ -314,13 +314,13 @@ final class Expectation /** * Asserts that the value contains the provided properties $names. * - * @param iterable $names + * @param iterable|iterable $names * @return self */ public function toHaveProperties(iterable $names, string $message = ''): self { foreach ($names as $name => $value) { - is_int($name) ? $this->toHaveProperty($value, message: $message) : $this->toHaveProperty($name, $value, $message); + is_int($name) ? $this->toHaveProperty($value, message: $message) : $this->toHaveProperty($name, $value, $message); // @phpstan-ignore-line } return $this; diff --git a/src/Pest.php b/src/Pest.php index 6a361fcf..992fc7f9 100644 --- a/src/Pest.php +++ b/src/Pest.php @@ -6,7 +6,7 @@ namespace Pest; function version(): string { - return '3.0.0-dev-0002'; + return '3.0.0-dev-0003'; } function testDirectory(string $file = ''): string diff --git a/src/Plugins/Cache.php b/src/Plugins/Cache.php index 682b938c..7312691b 100644 --- a/src/Plugins/Cache.php +++ b/src/Plugins/Cache.php @@ -31,10 +31,9 @@ final class Cache implements HandlesArguments public function handleArguments(array $arguments): array { if (! $this->hasArgument('--cache-directory', $arguments)) { - $arguments = $this->pushArgument( - sprintf('--cache-directory=%s', realpath(self::TEMPORARY_FOLDER)), - $arguments - ); + $arguments = $this->pushArgument('--cache-directory', $arguments); + + $arguments = $this->pushArgument((string) realpath(self::TEMPORARY_FOLDER), $arguments); } if (! $this->hasArgument('--parallel', $arguments)) { diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index c8e4d32f..9e1c3f53 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -93,10 +93,9 @@ final class Help implements HandlesArguments */ private function getContent(): array { - $helpReflection = new \ReflectionClass(PHPUnitHelp::class); + $helpReflection = new PHPUnitHelp(); - /** @var array> $content */ - $content = $helpReflection->getConstant('HELP_TEXT'); + $content = (fn (): array => $this->elements())->call($helpReflection); $content['Configuration'] = [...[[ 'arg' => '--init', diff --git a/src/Plugins/JUnit.php b/src/Plugins/JUnit.php new file mode 100644 index 00000000..3ee9bdf7 --- /dev/null +++ b/src/Plugins/JUnit.php @@ -0,0 +1,44 @@ +hasArgument('--log-junit', $arguments)) { + return $arguments; + } + + $logUnitArgument = null; + + $arguments = array_filter($arguments, function (string $argument) use (&$logUnitArgument): bool { + if (str_starts_with($argument, '--log-junit')) { + $logUnitArgument = $argument; + + return false; + } + + return true; + }); + + assert(is_string($logUnitArgument)); + + $arguments[] = $logUnitArgument; + + return array_values($arguments); + } +} diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index 9ef56fa2..0202cb4b 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -32,12 +32,12 @@ final class TestRepository private array $uses = []; /** - * @var array + * @var array */ private array $testCaseFilters = []; /** - * @var array + * @var array */ private array $testCaseMethodFilters = []; diff --git a/src/Subscribers/EnsureJunitEnabled.php b/src/Subscribers/EnsureJunitEnabled.php new file mode 100644 index 00000000..b57c32e6 --- /dev/null +++ b/src/Subscribers/EnsureJunitEnabled.php @@ -0,0 +1,48 @@ +input->hasParameterOption('--log-junit')) { + return; + } + + $configuration = Container::getInstance()->get(Configuration::class); + assert($configuration instanceof Configuration); + + new JUnitLogger( + DefaultPrinter::from($configuration->logfileJunit()), + new Converter($this->testSuite->rootPath), + ); + } +} diff --git a/src/Subscribers/EnsureTeamCityEnabled.php b/src/Subscribers/EnsureTeamCityEnabled.php index bd6b1bf2..264800c0 100644 --- a/src/Subscribers/EnsureTeamCityEnabled.php +++ b/src/Subscribers/EnsureTeamCityEnabled.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Pest\Subscribers; -use Pest\Logging\TeamCity\Converter; +use Pest\Logging\Converter; use Pest\Logging\TeamCity\TeamCityLogger; use Pest\TestSuite; use PHPUnit\Event\TestRunner\Configured; diff --git a/src/Support/HigherOrderMessageCollection.php b/src/Support/HigherOrderMessageCollection.php index 8f5be734..da13a16c 100644 --- a/src/Support/HigherOrderMessageCollection.php +++ b/src/Support/HigherOrderMessageCollection.php @@ -58,7 +58,7 @@ final class HigherOrderMessageCollection /** * Count the number of messages with the given name. * - * @param string $name A higher order message name (usually a method name) + * @param string $name A higher order message name (usually a method name) */ public function count(string $name): int { diff --git a/src/Support/Str.php b/src/Support/Str.php index 16fa58fc..754749e7 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -24,7 +24,7 @@ final class Str * Create a (unsecure & non-cryptographically safe) random alpha-numeric * string value. * - * @param int $length the length of the resulting randomized string + * @param int $length the length of the resulting randomized string * * @see https://github.com/laravel/framework/blob/4.2/src/Illuminate/Support/Str.php#L240-L242 */ diff --git a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap index b5e8ccdf..adce8060 100644 --- a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap +++ b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap @@ -1,5 +1,5 @@ - Pest Testing Framework 3.0.0-dev-0002. + Pest Testing Framework 3.0.0-dev-0003. USAGE: pest [options] @@ -30,6 +30,7 @@ --exclude-group [name] ........... Exclude tests from the specified group(s) --covers [name] ................. Only run tests that intend to cover [name] --uses [name] ..................... Only run tests that intend to use [name] + --list-test-files ................................ List available test files --list-tests .......................................... List available tests --list-tests-xml [file] ................. List available tests in XML format --filter [pattern] ............................... Filter which tests to run diff --git a/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap index 9680a59d..e09352f4 100644 --- a/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap +++ b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap @@ -1,3 +1,3 @@ - Pest Testing Framework 3.0.0-dev-0002. + Pest Testing Framework 3.0.0-dev-0003.