mirror of
https://github.com/pestphp/pest.git
synced 2026-03-06 15:57:21 +01:00
Add Initial teamcity support
This commit is contained in:
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
src/Factories/Annotations/AddsAnnotation.php
Normal file
21
src/Factories/Annotations/AddsAnnotation.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Factories\Annotations;
|
||||
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
|
||||
/**
|
||||
* @interal
|
||||
*/
|
||||
interface AddsAnnotation
|
||||
{
|
||||
/**
|
||||
* Adds annotations to method
|
||||
*
|
||||
* @param array<int, string> $annotations
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function __invoke(TestCaseMethodFactory $method, array $annotations): array;
|
||||
}
|
||||
@ -10,7 +10,7 @@ use Pest\Factories\TestCaseMethodFactory;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoversNothing
|
||||
final class CoversNothing implements AddsAnnotation
|
||||
{
|
||||
/**
|
||||
* Adds annotations regarding the "depends" feature.
|
||||
|
||||
@ -10,7 +10,7 @@ use Pest\Support\Str;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Depends
|
||||
final class Depends implements AddsAnnotation
|
||||
{
|
||||
/**
|
||||
* Adds annotations regarding the "depends" feature.
|
||||
|
||||
@ -9,7 +9,7 @@ use Pest\Factories\TestCaseMethodFactory;
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Groups
|
||||
final class Groups implements AddsAnnotation
|
||||
{
|
||||
/**
|
||||
* Adds annotations regarding the "groups" feature.
|
||||
|
||||
24
src/Factories/Annotations/TestDox.php
Normal file
24
src/Factories/Annotations/TestDox.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Factories\Annotations;
|
||||
|
||||
use Pest\Factories\TestCaseMethodFactory;
|
||||
|
||||
final class TestDox implements AddsAnnotation
|
||||
{
|
||||
/**
|
||||
* Add metadata via test dox for TeamCity
|
||||
*
|
||||
* @param array<int, string> $annotations
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function __invoke(TestCaseMethodFactory $method, array $annotations): array
|
||||
{
|
||||
// First test dox on class overrides the method name.
|
||||
$annotations[] = "@testdox $method->description";
|
||||
|
||||
return $annotations;
|
||||
}
|
||||
}
|
||||
@ -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<int, class-string>
|
||||
* @var array<int, class-string<AddsAnnotation>>
|
||||
*/
|
||||
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 {
|
||||
|
||||
@ -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<int, class-string> $annotationsToUse
|
||||
* @param array<int, class-string<AddsAnnotation>> $annotationsToUse
|
||||
* @param array<int, class-string<\Pest\Factories\Attributes\Attribute>> $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);
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
238
src/Logging/TeamCity/Converter.php
Normal file
238
src/Logging/TeamCity/Converter.php
Normal file
@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\State;
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Support\Str;
|
||||
use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestDox;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\TestData\TestDataCollection;
|
||||
use PHPUnit\Event\TestSuite\TestSuite;
|
||||
use PHPUnit\Framework\Exception as FrameworkException;
|
||||
use PHPUnit\Framework\IncompleteTestError;
|
||||
use PHPUnit\Framework\SkippedWithMessageException;
|
||||
use PHPUnit\Metadata\MetadataCollection;
|
||||
use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Converter
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private const PREFIX = 'P\\';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getTestCaseMethodName(Test $test): string
|
||||
{
|
||||
if (! $test instanceof TestMethod) {
|
||||
throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
|
||||
}
|
||||
|
||||
return $test->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 <path>`
|
||||
$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;
|
||||
}
|
||||
}
|
||||
133
src/Logging/TeamCity/ServiceMessage.php
Normal file
133
src/Logging/TeamCity/ServiceMessage.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity;
|
||||
|
||||
final class ServiceMessage
|
||||
{
|
||||
private static int|null $flowId = null;
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|null> $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;
|
||||
}
|
||||
}
|
||||
22
src/Logging/TeamCity/Subscriber/Subscriber.php
Normal file
22
src/Logging/TeamCity/Subscriber/Subscriber.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use Pest\Logging\TeamCity\TeamCityLogger;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
abstract class Subscriber
|
||||
{
|
||||
public function __construct(private readonly TeamCityLogger $logger)
|
||||
{
|
||||
}
|
||||
|
||||
final protected function logger(): TeamCityLogger
|
||||
{
|
||||
return $this->logger;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestConsideredRiskySubscriber extends Subscriber implements ConsideredRiskySubscriber
|
||||
{
|
||||
public function notify(ConsideredRisky $event): void
|
||||
{
|
||||
$this->logger()->testConsideredRisky($event);
|
||||
}
|
||||
}
|
||||
19
src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php
Normal file
19
src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\ErroredSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestErroredSubscriber extends Subscriber implements ErroredSubscriber
|
||||
{
|
||||
public function notify(Errored $event): void
|
||||
{
|
||||
$this->logger()->testErrored($event);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\TestRunner\ExecutionFinished;
|
||||
use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestExecutionFinishedSubscriber extends Subscriber implements ExecutionFinishedSubscriber
|
||||
{
|
||||
public function notify(ExecutionFinished $event): void
|
||||
{
|
||||
$this->logger()->testExecutionFinished($event);
|
||||
}
|
||||
}
|
||||
19
src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php
Normal file
19
src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\FailedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestFailedSubscriber extends Subscriber implements FailedSubscriber
|
||||
{
|
||||
public function notify(Failed $event): void
|
||||
{
|
||||
$this->logger()->testFailed($event);
|
||||
}
|
||||
}
|
||||
19
src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php
Normal file
19
src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestFinishedSubscriber extends Subscriber implements FinishedSubscriber
|
||||
{
|
||||
public function notify(Finished $event): void
|
||||
{
|
||||
$this->logger()->testFinished($event);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestMarkedIncompleteSubscriber extends Subscriber implements MarkedIncompleteSubscriber
|
||||
{
|
||||
public function notify(MarkedIncomplete $event): void
|
||||
{
|
||||
$this->logger()->testMarkedIncomplete($event);
|
||||
}
|
||||
}
|
||||
19
src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php
Normal file
19
src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestPreparedSubscriber extends Subscriber implements PreparedSubscriber
|
||||
{
|
||||
public function notify(Prepared $event): void
|
||||
{
|
||||
$this->logger()->testPrepared($event);
|
||||
}
|
||||
}
|
||||
19
src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php
Normal file
19
src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\Test\SkippedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
|
||||
{
|
||||
public function notify(Skipped $event): void
|
||||
{
|
||||
$this->logger()->testSkipped($event);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\TestSuite\Finished;
|
||||
use PHPUnit\Event\TestSuite\FinishedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestSuiteFinishedSubscriber extends Subscriber implements FinishedSubscriber
|
||||
{
|
||||
public function notify(Finished $event): void
|
||||
{
|
||||
$this->logger()->testSuiteFinished($event);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity\Subscriber;
|
||||
|
||||
use PHPUnit\Event\TestSuite\Started;
|
||||
use PHPUnit\Event\TestSuite\StartedSubscriber;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TestSuiteStartedSubscriber extends Subscriber implements StartedSubscriber
|
||||
{
|
||||
public function notify(Started $event): void
|
||||
{
|
||||
$this->logger()->testSuiteStarted($event);
|
||||
}
|
||||
}
|
||||
251
src/Logging/TeamCity/TeamCityLogger.php
Normal file
251
src/Logging/TeamCity/TeamCityLogger.php
Normal file
@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Logging\TeamCity;
|
||||
|
||||
use NunoMaduro\Collision\Adapters\Phpunit\Style;
|
||||
use Pest\Exceptions\ShouldNotHappen;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestConsideredRiskySubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestErroredSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestExecutionFinishedSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestFailedSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestFinishedSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestMarkedIncompleteSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestPreparedSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestSkippedSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestSuiteFinishedSubscriber;
|
||||
use Pest\Logging\TeamCity\Subscriber\TestSuiteStartedSubscriber;
|
||||
use PHPUnit\Event\EventFacadeIsSealedException;
|
||||
use PHPUnit\Event\Facade;
|
||||
use PHPUnit\Event\Telemetry\Duration;
|
||||
use PHPUnit\Event\Telemetry\HRTime;
|
||||
use PHPUnit\Event\Telemetry\Info;
|
||||
use PHPUnit\Event\Telemetry\Snapshot;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\MarkedIncomplete;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\Skipped;
|
||||
use PHPUnit\Event\TestRunner\ExecutionFinished;
|
||||
use PHPUnit\Event\TestSuite\Finished as TestSuiteFinished;
|
||||
use PHPUnit\Event\TestSuite\Started as TestSuiteStarted;
|
||||
use PHPUnit\Event\UnknownSubscriberTypeException;
|
||||
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TeamCityLogger
|
||||
{
|
||||
private ?HRTime $time = null;
|
||||
|
||||
/**
|
||||
* @throws EventFacadeIsSealedException
|
||||
* @throws UnknownSubscriberTypeException
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
private readonly Converter $converter,
|
||||
private readonly int|null $flowId,
|
||||
private readonly bool $withoutDuration,
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@ -29,7 +29,7 @@ final class TestRepository
|
||||
private array $uses = [];
|
||||
|
||||
/**
|
||||
* @var array<int, TestCaseFilter>
|
||||
* @var array<int, TestCaseFilter>
|
||||
*/
|
||||
private array $filters = [];
|
||||
|
||||
|
||||
46
src/Subscribers/EnsureTeamCityEnabled.php
Normal file
46
src/Subscribers/EnsureTeamCityEnabled.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Logging\TeamCity\Converter;
|
||||
use Pest\Logging\TeamCity\TeamCityLogger;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Event\TestRunner\Configured;
|
||||
use PHPUnit\Event\TestRunner\ConfiguredSubscriber;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EnsureTeamCityEnabled implements ConfiguredSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
private readonly InputInterface $input,
|
||||
private readonly TestSuite $testSuite,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the subscriber.
|
||||
*/
|
||||
public function notify(Configured $event): void
|
||||
{
|
||||
if (! $this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<int, array<string, string>> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user