From 0839c7e127a668c38658e64032d05e025b23d9cd Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 8 Jan 2023 11:21:08 +0100 Subject: [PATCH] Add Initial teamcity support --- bin/pest | 28 +- composer.json | 1 + phpunit.xml | 2 + src/Bootstrappers/BootSubscribers.php | 11 +- src/Factories/Annotations/AddsAnnotation.php | 21 ++ src/Factories/Annotations/CoversNothing.php | 2 +- src/Factories/Annotations/Depends.php | 2 +- src/Factories/Annotations/Groups.php | 2 +- src/Factories/Annotations/TestDox.php | 24 ++ src/Factories/TestCaseFactory.php | 8 +- src/Factories/TestCaseMethodFactory.php | 4 +- src/Kernel.php | 4 +- src/Logging/TeamCity/Converter.php | 238 +++++++++++++++++ src/Logging/TeamCity/ServiceMessage.php | 133 ++++++++++ .../TeamCity/Subscriber/Subscriber.php | 22 ++ .../TestConsideredRiskySubscriber.php | 19 ++ .../Subscriber/TestErroredSubscriber.php | 19 ++ .../TestExecutionFinishedSubscriber.php | 19 ++ .../Subscriber/TestFailedSubscriber.php | 19 ++ .../Subscriber/TestFinishedSubscriber.php | 19 ++ .../TestMarkedIncompleteSubscriber.php | 19 ++ .../Subscriber/TestPreparedSubscriber.php | 19 ++ .../Subscriber/TestSkippedSubscriber.php | 19 ++ .../TestSuiteFinishedSubscriber.php | 19 ++ .../Subscriber/TestSuiteStartedSubscriber.php | 19 ++ src/Logging/TeamCity/TeamCityLogger.php | 251 ++++++++++++++++++ src/Repositories/TestRepository.php | 2 +- src/Subscribers/EnsureTeamCityEnabled.php | 46 ++++ src/Support/ExceptionTrace.php | 29 -- src/Support/Str.php | 5 + tests/.snapshots/Failure.php.inc | 24 ++ tests/.snapshots/SuccessOnly.php.inc | 10 + tests/.snapshots/success.txt | 5 +- tests/.tests/Failure.php | 26 ++ tests/.tests/SuccessOnly.php | 11 + tests/Visual/TeamCity.php | 64 +++-- 36 files changed, 1087 insertions(+), 78 deletions(-) create mode 100644 src/Factories/Annotations/AddsAnnotation.php create mode 100644 src/Factories/Annotations/TestDox.php create mode 100644 src/Logging/TeamCity/Converter.php create mode 100644 src/Logging/TeamCity/ServiceMessage.php create mode 100644 src/Logging/TeamCity/Subscriber/Subscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php create mode 100644 src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php create mode 100644 src/Logging/TeamCity/TeamCityLogger.php create mode 100644 src/Subscribers/EnsureTeamCityEnabled.php create mode 100644 tests/.snapshots/Failure.php.inc create mode 100644 tests/.snapshots/SuccessOnly.php.inc create mode 100644 tests/.tests/Failure.php create mode 100644 tests/.tests/SuccessOnly.php diff --git a/bin/pest b/bin/pest index 7b31c0b1..16fcaeb5 100755 --- a/bin/pest +++ b/bin/pest @@ -1,13 +1,13 @@ #!/usr/bin/env php $value) { if (str_contains($value, '--compact')) { $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; + unset($args[$key]); } if (str_contains($value, '--profile')) { $_SERVER['COLLISION_PRINTER_PROFILE'] = 'true'; + unset($args[$key]); + } + + if (str_contains($value, '--test-directory')) { + unset($args[$key]); + } + + if (str_contains($value, '--dirty')) { + unset($args[$key]); + } + + if (str_contains($value, '--teamcity')) { + unset($args[$key]); + $args[] = '--no-output'; + unset($_SERVER['COLLISION_PRINTER']); } } @@ -63,14 +79,8 @@ use Symfony\Component\Console\Output\OutputInterface; $container = Container::getInstance(); $container->add(TestSuite::class, $testSuite); $container->add(OutputInterface::class, $output); - - $argsToUnset = ['--test-directory', '--compact', '--profile', '--dirty']; - - foreach ($args as $key => $value) { - if (in_array($value, $argsToUnset)) { - unset($args[$key]); - } - } + $container->add(InputInterface::class, $argv); + $container->add(Container::class, $container); $kernel = Kernel::boot(); diff --git a/composer.json b/composer.json index 557eebcf..367152e8 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "pestphp/pest-plugin": "^2.0.0", "phpunit/phpunit": "10.0.x-dev" }, + "version": "2.x-dev", "autoload": { "psr-4": { "Pest\\": "src/" diff --git a/phpunit.xml b/phpunit.xml index 2811352b..d1b754d0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,6 +24,8 @@ ./tests + ./tests/.snapshots + ./tests/.tests diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index ab31e337..35a46696 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -6,6 +6,7 @@ namespace Pest\Bootstrappers; use Pest\Contracts\Bootstrapper; use Pest\Subscribers; +use Pest\Support\Container; use PHPUnit\Event; use PHPUnit\Event\Subscriber; @@ -25,16 +26,24 @@ final class BootSubscribers implements Bootstrapper Subscribers\EnsureRetryRepositoryExists::class, Subscribers\EnsureErroredTestsAreRetryable::class, Subscribers\EnsureFailedTestsAreRetryable::class, + Subscribers\EnsureTeamCityEnabled::class, ]; + public function __construct( + private readonly Container $container, + ) { + } + /** * Boots the Subscribers. */ public function boot(): void { foreach (self::SUBSCRIBERS as $subscriber) { + /** @var Subscriber $instance */ + $instance = $this->container->get($subscriber); Event\Facade::registerSubscriber( - new $subscriber() + $instance ); } } diff --git a/src/Factories/Annotations/AddsAnnotation.php b/src/Factories/Annotations/AddsAnnotation.php new file mode 100644 index 00000000..1c92ae1f --- /dev/null +++ b/src/Factories/Annotations/AddsAnnotation.php @@ -0,0 +1,21 @@ + $annotations + * @return array + */ + public function __invoke(TestCaseMethodFactory $method, array $annotations): array; +} diff --git a/src/Factories/Annotations/CoversNothing.php b/src/Factories/Annotations/CoversNothing.php index 956a639f..05bb64ef 100644 --- a/src/Factories/Annotations/CoversNothing.php +++ b/src/Factories/Annotations/CoversNothing.php @@ -10,7 +10,7 @@ use Pest\Factories\TestCaseMethodFactory; /** * @internal */ -final class CoversNothing +final class CoversNothing implements AddsAnnotation { /** * Adds annotations regarding the "depends" feature. diff --git a/src/Factories/Annotations/Depends.php b/src/Factories/Annotations/Depends.php index 5647081f..09e38977 100644 --- a/src/Factories/Annotations/Depends.php +++ b/src/Factories/Annotations/Depends.php @@ -10,7 +10,7 @@ use Pest\Support\Str; /** * @internal */ -final class Depends +final class Depends implements AddsAnnotation { /** * Adds annotations regarding the "depends" feature. diff --git a/src/Factories/Annotations/Groups.php b/src/Factories/Annotations/Groups.php index 9fb61f8a..80641123 100644 --- a/src/Factories/Annotations/Groups.php +++ b/src/Factories/Annotations/Groups.php @@ -9,7 +9,7 @@ use Pest\Factories\TestCaseMethodFactory; /** * @internal */ -final class Groups +final class Groups implements AddsAnnotation { /** * Adds annotations regarding the "groups" feature. diff --git a/src/Factories/Annotations/TestDox.php b/src/Factories/Annotations/TestDox.php new file mode 100644 index 00000000..33689395 --- /dev/null +++ b/src/Factories/Annotations/TestDox.php @@ -0,0 +1,24 @@ + $annotations + * @return array + */ + public function __invoke(TestCaseMethodFactory $method, array $annotations): array + { + // First test dox on class overrides the method name. + $annotations[] = "@testdox $method->description"; + + return $annotations; + } +} diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index 9a48cd5c..be73592a 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -11,6 +11,7 @@ use Pest\Exceptions\DatasetMissing; use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\TestAlreadyExist; use Pest\Exceptions\TestDescriptionMissing; +use Pest\Factories\Annotations\AddsAnnotation; use Pest\Factories\Concerns\HigherOrderable; use Pest\Plugins\Environment; use Pest\Support\Reflection; @@ -29,12 +30,13 @@ final class TestCaseFactory /** * The list of annotations. * - * @var array + * @var array> */ private const ANNOTATIONS = [ Annotations\Depends::class, Annotations\Groups::class, Annotations\CoversNothing::class, + Annotations\TestDox::class, ]; /** @@ -198,6 +200,10 @@ final class TestCaseFactory use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\TestSuite as __PestTestSuite; + + /** + * @testdox $filename + */ $classAttributesCode #[\AllowDynamicProperties] final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index 1bca4dd9..1eb5a6d4 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -6,6 +6,7 @@ namespace Pest\Factories; use Closure; use Pest\Exceptions\ShouldNotHappen; +use Pest\Factories\Annotations\AddsAnnotation; use Pest\Factories\Concerns\HigherOrderable; use Pest\Plugins\Retry; use Pest\Repositories\DatasetsRepository; @@ -112,7 +113,7 @@ final class TestCaseMethodFactory /** * Creates a PHPUnit method as a string ready for evaluation. * - * @param array $annotationsToUse + * @param array> $annotationsToUse * @param array> $attributesToUse */ public function buildForEvaluation(string $classFQN, array $annotationsToUse, array $attributesToUse): string @@ -134,7 +135,6 @@ final class TestCaseMethodFactory $attributes = []; foreach ($annotationsToUse as $annotation) { - /** @phpstan-ignore-next-line */ $annotations = (new $annotation())->__invoke($this, $annotations); } diff --git a/src/Kernel.php b/src/Kernel.php index aa8afa34..ac28fada 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -73,9 +73,7 @@ final class Kernel { $argv = (new Plugins\Actions\CallsHandleArguments())->__invoke($argv); - $this->application->run( - $argv, false, - ); + $this->application->run($argv, false); return (new CallsAddsOutput())->__invoke( Result::exitCode(), diff --git a/src/Logging/TeamCity/Converter.php b/src/Logging/TeamCity/Converter.php new file mode 100644 index 00000000..e62d4d61 --- /dev/null +++ b/src/Logging/TeamCity/Converter.php @@ -0,0 +1,238 @@ +testDox()->prettifiedMethodName(); + } + + public function getTestCaseLocation(Test $test): string + { + if (! $test instanceof TestMethod) { + throw ShouldNotHappen::fromMessage('Not an instance of TestMethod'); + } + + $fileName = $test->testDox()->prettifiedClassName(); + $fileName = $this->cleanPath($fileName); + + // TODO: Get the description without the dataset. + $description = $test->testDox()->prettifiedMethodName(); + + return "$fileName::$description"; + } + + public function getExceptionMessage(Throwable $throwable): string + { + if (is_a($throwable->className(), FrameworkException::class, true)) { + return $throwable->message(); + } + + $buffer = $throwable->className(); + $throwableMessage = $throwable->message(); + + if ($throwableMessage !== '') { + $buffer .= ": $throwableMessage"; + } + + return $buffer; + } + + public function getExceptionDetails(Throwable $throwable): string + { + $buffer = $this->getStackTrace($throwable); + + while ($throwable->hasPrevious()) { + $throwable = $throwable->previous(); + + $buffer .= sprintf( + "\nCaused by\n%s\n%s", + $throwable->description(), + $this->getStackTrace($throwable) + ); + } + + return $buffer; + } + + public function getStackTrace(Throwable $throwable): string + { + $stackTrace = $throwable->stackTrace(); + + // Split stacktrace per frame. + $frames = explode("\n", $stackTrace); + + // Remove empty lines + $frames = array_filter($frames); + + // clean the paths of each frame. + $frames = array_map( + fn (string $frame): string => $this->cleanPath($frame), + $frames + ); + + // Format stacktrace as `at ` + $frames = array_map( + fn (string $frame) => "at $frame", + $frames + ); + + return implode("\n", $frames); + } + + public function getTestSuiteName(TestSuite $testSuite): string + { + $name = $testSuite->name(); + + if (! str_starts_with($name, self::PREFIX)) { + return $name; + } + + return Str::after($name, self::PREFIX); + } + + public function getTestSuiteLocation(TestSuite $testSuite): string|null + { + $tests = $testSuite->tests()->asArray(); + + // TODO: figure out how to get the file path without a test being there. + if ($tests === []) { + return null; + } + + $firstTest = $tests[0]; + if (! $firstTest instanceof TestMethod) { + throw ShouldNotHappen::fromMessage('Not an instance of TestMethod'); + } + + $path = $firstTest->testDox()->prettifiedClassName(); + + return $this->cleanPath($path); + } + + private function cleanPath(string $path): string + { + // Remove cwd from the path. + return str_replace("$this->rootPath/", '', $path); + } + + public function getStateFromResult(PhpUnitTestResult $result): State + { + $state = new State(); + + foreach ($result->testErroredEvents() as $resultEvent) { + assert($resultEvent instanceof Errored); + $state->add(TestResult::fromTestCase( + $resultEvent->test(), + TestResult::FAIL, + $resultEvent->throwable() + )); + } + + foreach ($result->testFailedEvents() as $resultEvent) { + $state->add(TestResult::fromTestCase( + $resultEvent->test(), + TestResult::FAIL, + $resultEvent->throwable() + )); + } + + foreach ($result->testMarkedIncompleteEvents() as $resultEvent) { + $state->add(TestResult::fromTestCase( + $resultEvent->test(), + TestResult::INCOMPLETE, + $resultEvent->throwable() + )); + } + + foreach ($result->testConsideredRiskyEvents() as $riskyEvents) { + foreach ($riskyEvents as $riskyEvent) { + $state->add(TestResult::fromTestCase( + $riskyEvent->test(), + TestResult::RISKY, + Throwable::from(new IncompleteTestError($riskyEvent->message())) + )); + } + } + + foreach ($result->testSkippedEvents() as $resultEvent) { + if ($resultEvent->message() === '__TODO__') { + $state->add(TestResult::fromTestCase($resultEvent->test(), TestResult::TODO)); + + continue; + } + + $state->add(TestResult::fromTestCase( + $resultEvent->test(), + TestResult::SKIPPED, + Throwable::from(new SkippedWithMessageException($resultEvent->message())) + )); + } + + $numberOfPassedTests = $result->numberOfTests() + - $result->numberOfTestErroredEvents() + - $result->numberOfTestFailedEvents() + - $result->numberOfTestSkippedEvents() + - $result->numberOfTestsWithTestConsideredRiskyEvents() + - $result->numberOfTestMarkedIncompleteEvents(); + + for ($i = 0; $i < $numberOfPassedTests; $i++) { + $state->add(TestResult::fromTestCase( + + new TestMethod( + /** @phpstan-ignore-next-line */ + "$i", + /** @phpstan-ignore-next-line */ + '', + '', + 1, + /** @phpstan-ignore-next-line */ + TestDox::fromClassNameAndMethodName('', ''), + MetadataCollection::fromArray([]), + TestDataCollection::fromArray([]) + ), + TestResult::PASS + )); + } + + return $state; + } +} diff --git a/src/Logging/TeamCity/ServiceMessage.php b/src/Logging/TeamCity/ServiceMessage.php new file mode 100644 index 00000000..61be652e --- /dev/null +++ b/src/Logging/TeamCity/ServiceMessage.php @@ -0,0 +1,133 @@ + $parameters + */ + public function __construct( + private readonly string $type, + private readonly array $parameters, + ) { + } + + public function toString(): string + { + $paramsToString = ''; + + foreach ([...$this->parameters, 'flowId' => self::$flowId] as $key => $value) { + $value = self::escapeServiceMessage((string) $value); + $paramsToString .= " $key='$value'"; + } + + return "##teamcity[$this->type$paramsToString]"; + } + + public static function testSuiteStarted(string $name, string|null $location): self + { + return new self('testSuiteStarted', [ + 'name' => $name, + 'locationHint' => $location === null ? null : "file://$location", + ]); + } + + public static function testSuiteFinished(string $name): self + { + return new self('testSuiteFinished', [ + 'name' => $name, + ]); + } + + public static function testStarted(string $name, string $location): self + { + return new self('testStarted', [ + 'name' => $name, + 'locationHint' => "pest_qn://$location", + ]); + } + + /** + * @param int $duration in milliseconds + */ + public static function testFinished(string $name, int $duration): self + { + return new self('testFinished', [ + 'name' => $name, + 'duration' => $duration, + ]); + } + + public static function testStdOut(string $name, string $data): self + { + if (! str_ends_with($data, "\n")) { + $data .= "\n"; + } + + return new self('testStdOut', [ + 'name' => $name, + 'out' => $data, + ]); + } + + public static function testFailed(string $name, string $message, string $details): self + { + return new self('testFailed', [ + 'name' => $name, + 'message' => $message, + 'details' => $details, + ]); + } + + public static function testStdErr(string $name, string $data): self + { + if (! str_ends_with($data, "\n")) { + $data .= "\n"; + } + + return new self('testStdErr', [ + 'name' => $name, + 'out' => $data, + ]); + } + + public static function testIgnored(string $name, string $message, string $details = null): self + { + return new self('testIgnored', [ + 'name' => $name, + 'message' => $message, + 'details' => $details, + ]); + } + + public static function comparisonFailure(string $name, string $message, string $details, string $actual, string $expected): self + { + return new self('testFailed', [ + 'name' => $name, + 'message' => $message, + 'details' => $details, + 'type' => 'comparisonFailure', + 'actual' => $actual, + 'expected' => $expected, + ]); + } + + private static function escapeServiceMessage(string $text): string + { + return str_replace( + ['|', "'", "\n", "\r", ']', '['], + ['||', "|'", '|n', '|r', '|]', '|['], + $text + ); + } + + public static function setFlowId(int $flowId): void + { + self::$flowId = $flowId; + } +} diff --git a/src/Logging/TeamCity/Subscriber/Subscriber.php b/src/Logging/TeamCity/Subscriber/Subscriber.php new file mode 100644 index 00000000..1ea92483 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/Subscriber.php @@ -0,0 +1,22 @@ +logger; + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php b/src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php new file mode 100644 index 00000000..75a49997 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php @@ -0,0 +1,19 @@ +logger()->testConsideredRisky($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php b/src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php new file mode 100644 index 00000000..29c61627 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php @@ -0,0 +1,19 @@ +logger()->testErrored($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php new file mode 100644 index 00000000..3592b6b9 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testExecutionFinished($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php new file mode 100644 index 00000000..ec25ce02 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testFailed($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php new file mode 100644 index 00000000..8e04c338 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testFinished($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php b/src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php new file mode 100644 index 00000000..b9c4aa66 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php @@ -0,0 +1,19 @@ +logger()->testMarkedIncomplete($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php new file mode 100644 index 00000000..7b62b072 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testPrepared($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php new file mode 100644 index 00000000..9e6b284b --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSkipped($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php new file mode 100644 index 00000000..c4dd32c7 --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSuiteFinished($event); + } +} diff --git a/src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php new file mode 100644 index 00000000..7c5d5dea --- /dev/null +++ b/src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php @@ -0,0 +1,19 @@ +logger()->testSuiteStarted($event); + } +} diff --git a/src/Logging/TeamCity/TeamCityLogger.php b/src/Logging/TeamCity/TeamCityLogger.php new file mode 100644 index 00000000..3ab17550 --- /dev/null +++ b/src/Logging/TeamCity/TeamCityLogger.php @@ -0,0 +1,251 @@ +registerSubscribers(); + $this->setFlowId(); + } + + public function testSuiteStarted(TestSuiteStarted $event): void + { + $message = ServiceMessage::testSuiteStarted( + $this->converter->getTestSuiteName($event->testSuite()), + $this->converter->getTestSuiteLocation($event->testSuite()) + ); + + $this->output($message); + } + + public function testSuiteFinished(TestSuiteFinished $event): void + { + $message = ServiceMessage::testSuiteFinished( + $this->converter->getTestSuiteName($event->testSuite()), + ); + + $this->output($message); + } + + public function testPrepared(Prepared $event): void + { + $message = ServiceMessage::testStarted( + $this->converter->getTestCaseMethodName($event->test()), + $this->converter->getTestCaseLocation($event->test()), + ); + + $this->output($message); + + $this->time = $event->telemetryInfo()->time(); + } + + public function testMarkedIncomplete(MarkedIncomplete $event): never + { + // TODO: when does this trigger? + throw ShouldNotHappen::fromMessage('testMarkedIncomplete not implemented.'); + } + + public function testSkipped(Skipped $event): void + { + $message = ServiceMessage::testIgnored( + $this->converter->getTestCaseMethodName($event->test()), + 'This test was ignored.' + ); + + $this->output($message); + } + + /** + * This will trigger in the following scenarios + * - When an exception is thrown + */ + public function testErrored(Errored $event): void + { + $testName = $this->converter->getTestCaseMethodName($event->test()); + $message = $this->converter->getExceptionMessage($event->throwable()); + $details = $this->converter->getExceptionDetails($event->throwable()); + + $message = ServiceMessage::testFailed( + $testName, + $message, + $details, + ); + + $this->output($message); + } + + /** + * This will trigger in the following scenarios + * - When an assertion fails + */ + public function testFailed(Failed $event): void + { + $testName = $this->converter->getTestCaseMethodName($event->test()); + $message = $this->converter->getExceptionMessage($event->throwable()); + $details = $this->converter->getExceptionDetails($event->throwable()); + + if ($event->hasComparisonFailure()) { + $comparison = $event->comparisonFailure(); + $message = ServiceMessage::comparisonFailure( + $testName, + $message, + $details, + $comparison->actual(), + $comparison->expected() + ); + } else { + $message = ServiceMessage::testFailed( + $testName, + $message, + $details, + ); + } + + $this->output($message); + } + + /** + * This will trigger in the following scenarios + * - When no assertions in a test + */ + public function testConsideredRisky(ConsideredRisky $event): void + { + $message = ServiceMessage::testIgnored( + $this->converter->getTestCaseMethodName($event->test()), + $event->message() + ); + + $this->output($message); + } + + public function testFinished(Finished $event): void + { + if ($this->time === null) { + throw ShouldNotHappen::fromMessage('Start time has not been set.'); + } + + $testName = $this->converter->getTestCaseMethodName($event->test()); + $duration = $event->telemetryInfo()->time()->duration($this->time)->asFloat(); + if ($this->withoutDuration) { + $duration = 100; + } + + $message = ServiceMessage::testFinished( + $testName, + (int) ($duration * 1000) + ); + + $this->output($message); + } + + public function testExecutionFinished(ExecutionFinished $event): void + { + $result = TestResultFacade::result(); + $state = $this->converter->getStateFromResult($result); + + assert($this->output instanceof ConsoleOutput); + $style = new Style($this->output); + + $telemetry = $event->telemetryInfo(); + if ($this->withoutDuration) { + $telemetry = new Info( + new Snapshot( + $telemetry->time(), + $telemetry->memoryUsage(), + $telemetry->peakMemoryUsage(), + ), + Duration::fromSecondsAndNanoseconds(1, 0), + $telemetry->memoryUsageSinceStart(), + $telemetry->durationSincePrevious(), + $telemetry->memoryUsageSincePrevious(), + $telemetry->emitter(), + ); + } + + $style->writeRecap($state, $telemetry); + } + + public function output(ServiceMessage $message): void + { + $this->output->writeln("{$message->toString()}"); + } + + /** + * @throws EventFacadeIsSealedException + * @throws UnknownSubscriberTypeException + */ + private function registerSubscribers(): void + { + Facade::registerSubscribers( + 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 TestConsideredRiskySubscriber($this), + new TestExecutionFinishedSubscriber($this), + ); + } + + private function setFlowId(): void + { + if ($this->flowId === null) { + return; + } + + ServiceMessage::setFlowId($this->flowId); + } +} diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index eb4e4936..4cb98940 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -29,7 +29,7 @@ final class TestRepository private array $uses = []; /** - * @var array + * @var array */ private array $filters = []; diff --git a/src/Subscribers/EnsureTeamCityEnabled.php b/src/Subscribers/EnsureTeamCityEnabled.php new file mode 100644 index 00000000..8e1f5d6e --- /dev/null +++ b/src/Subscribers/EnsureTeamCityEnabled.php @@ -0,0 +1,46 @@ +input->hasParameterOption('--teamcity')) { + return; + } + + $flowId = getenv('FLOW_ID'); + $flowId = is_string($flowId) ? (int) $flowId : getmypid(); + + new TeamCityLogger( + $this->output, + new Converter($this->testSuite->rootPath), + $flowId === false ? null : $flowId, + getenv('COLLISION_IGNORE_DURATION') !== false + ); + } +} diff --git a/src/Support/ExceptionTrace.php b/src/Support/ExceptionTrace.php index faa5080b..014b88df 100644 --- a/src/Support/ExceptionTrace.php +++ b/src/Support/ExceptionTrace.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Pest\Support; use Closure; -use ReflectionProperty; use Throwable; /** @@ -37,32 +36,4 @@ final class ExceptionTrace throw $throwable; } } - - /** - * Removes any item from the stack trace referencing Pest so as not to - * crowd the error log for the end user. - */ - public static function removePestReferences(Throwable $t): void - { - if (! property_exists($t, 'serializableTrace')) { - return; - } - - $property = new ReflectionProperty($t, 'serializableTrace'); - $property->setAccessible(true); - - /** @var array> $trace */ - $trace = $property->getValue($t); - - $cleanedTrace = []; - foreach ($trace as $item) { - if (array_key_exists('file', $item) && mb_strpos($item['file'], 'vendor/pestphp/pest/') > 0) { - continue; - } - - $cleanedTrace[] = $item; - } - - $property->setValue($t, $cleanedTrace); - } } diff --git a/src/Support/Str.php b/src/Support/Str.php index 6ceaed1d..1ad98a77 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -76,4 +76,9 @@ final class Str return substr($subject, 0, $pos); } + + public static function after(string $subject, string $search): string + { + return $search === '' ? $subject : array_reverse(explode($search, $subject, 2))[0]; + } } diff --git a/tests/.snapshots/Failure.php.inc b/tests/.snapshots/Failure.php.inc new file mode 100644 index 00000000..10207012 --- /dev/null +++ b/tests/.snapshots/Failure.php.inc @@ -0,0 +1,24 @@ +##teamcity[testSuiteStarted name='Tests\tests\Failure' locationHint='file://tests/.tests/Failure.php' flowId='1234'] +##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234'] +##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at src/Mixins/Expectation.php:312|nat src/Support/ExpectationPipeline.php:75|nat src/Support/ExpectationPipeline.php:79|nat src/Expectation.php:300|nat tests/.tests/Failure.php:6|nat src/Factories/TestCaseMethodFactory.php:101|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:76' type='comparisonFailure' actual='true' expected='false' flowId='1234'] +##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234'] +##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234'] +##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234'] +##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234'] +##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234'] +##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234'] +##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234'] +##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234'] +##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18|nat src/Factories/TestCaseMethodFactory.php:101|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:76' flowId='1234'] +##teamcity[testFinished name='it can fail' duration='100000' flowId='1234'] +##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234'] +##teamcity[testIgnored name='it is not done yet' message='This test was ignored.' details='' flowId='1234'] +##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234'] +##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234'] +##teamcity[testIgnored name='build this one.' message='This test was ignored.' details='' flowId='1234'] +##teamcity[testFinished name='build this one.' duration='100000' flowId='1234'] +##teamcity[testSuiteFinished name='Tests\tests\Failure' flowId='1234'] + + Tests: 2 failed, 1 risky, 2 todos, 1 skipped (2 assertions) + Duration: 1.00s + diff --git a/tests/.snapshots/SuccessOnly.php.inc b/tests/.snapshots/SuccessOnly.php.inc new file mode 100644 index 00000000..cdcb71a9 --- /dev/null +++ b/tests/.snapshots/SuccessOnly.php.inc @@ -0,0 +1,10 @@ +##teamcity[testSuiteStarted name='Tests\tests\SuccessOnly' locationHint='file://tests/.tests/SuccessOnly.php' flowId='1234'] +##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234'] +##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234'] +##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234'] +##teamcity[testFinished name='can also pass' duration='100000' flowId='1234'] +##teamcity[testSuiteFinished name='Tests\tests\SuccessOnly' flowId='1234'] + + Tests: 2 passed (2 assertions) + Duration: 1.00s + diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index e91aae32..7d26d0b0 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -893,9 +893,10 @@ - visual snapshot of test suite on success WARN Tests\Visual\TeamCity - - it is can successfully call all public methods → Not supported yet. + - visual snapshot of team city with ('Failure.php') + - visual snapshot of team city with ('SuccessOnly.php') PASS Tests\Visual\Version ✓ visual snapshot of help command output - Tests: 4 incomplete, 4 todos, 17 skipped, 624 passed (1511 assertions) \ No newline at end of file + Tests: 4 incomplete, 4 todos, 18 skipped, 624 passed (1511 assertions) \ No newline at end of file diff --git a/tests/.tests/Failure.php b/tests/.tests/Failure.php new file mode 100644 index 00000000..90d92593 --- /dev/null +++ b/tests/.tests/Failure.php @@ -0,0 +1,26 @@ +toEqual(false); +}); + +it('can be ignored because of no assertions', function () { + +}); + +it('can be ignored because it is skipped', function () { + expect(true)->toBeTrue(); +})->skip("this is why"); + +it('can fail', function () { + $this->fail("oh noo"); +}); + +it('is not done yet', function () { + +})->todo(); + +todo("build this one."); + diff --git a/tests/.tests/SuccessOnly.php b/tests/.tests/SuccessOnly.php new file mode 100644 index 00000000..38672a0d --- /dev/null +++ b/tests/.tests/SuccessOnly.php @@ -0,0 +1,11 @@ +toEqual(true); +}); + +test('can also pass', function () { + expect("string")->toBeString(); +}); diff --git a/tests/Visual/TeamCity.php b/tests/Visual/TeamCity.php index a3f9e7b2..0ea3bc08 100644 --- a/tests/Visual/TeamCity.php +++ b/tests/Visual/TeamCity.php @@ -1,32 +1,42 @@ toBeTrue(); - $teamCity->startTestSuite(new TestSuite()); - $teamCity->startTest($this); - $teamCity->addError($this, new Exception('Don\'t worry about this error. Its purposeful.'), 0); - $teamCity->addFailure($this, new AssertionFailedError('Don\'t worry about this error. Its purposeful.'), 0); - $teamCity->addWarning($this, new Warning(), 0); - $teamCity->addIncompleteTest($this, new Exception(), 0); - $teamCity->addRiskyTest($this, new Exception(), 0); - $teamCity->addSkippedTest($this, new Exception(), 0); - $teamCity->endTest($this, 0); - $teamCity->printResult(new TestResult()); - $teamCity->endTestSuite(new TestSuite()); -})->skip('Not supported yet.'); + $output = function () use ($testsPath) { + $process = (new Symfony\Component\Process\Process( + ['php', 'bin/pest', '--teamcity', $testsPath], + dirname(__DIR__, levels: 2), + [ + 'EXCLUDE' => 'integration', + 'REBUILD_SNAPSHOTS' => false, + 'PARATEST' => 0, + 'COLLISION_IGNORE_DURATION' => 'true', + 'FLOW_ID' => '1234', + ], + )); -afterEach(function () { - unlink(__DIR__.'/output.txt'); -}); + $process->run(); + + return $process->getOutput(); + }; + + if (getenv('REBUILD_SNAPSHOTS')) { + $outputContent = explode("\n", $output()); + + file_put_contents($snapshot, implode("\n", $outputContent)); + } elseif (! getenv('EXCLUDE')) { + $output = explode("\n", $output()); + + expect(implode("\n", $output))->toEqual(file_get_contents($snapshot)); + } +})->with([ + 'Failure.php', + 'SuccessOnly.php', +])->skip(! getenv('REBUILD_SNAPSHOTS') && getenv('EXCLUDE'));