Add Initial teamcity support

This commit is contained in:
Oliver
2023-01-08 11:21:08 +01:00
parent 15931e2418
commit 0839c7e127
36 changed files with 1087 additions and 78 deletions

View File

@ -1,13 +1,13 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php declare(strict_types=1); <?php declare(strict_types=1);
use Pest\Actions\ValidatesEnvironment;
use Pest\ConfigLoader; use Pest\ConfigLoader;
use Pest\Kernel; use Pest\Kernel;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -20,10 +20,26 @@ use Symfony\Component\Console\Output\OutputInterface;
foreach ($args as $key => $value) { foreach ($args as $key => $value) {
if (str_contains($value, '--compact')) { if (str_contains($value, '--compact')) {
$_SERVER['COLLISION_PRINTER_COMPACT'] = 'true'; $_SERVER['COLLISION_PRINTER_COMPACT'] = 'true';
unset($args[$key]);
} }
if (str_contains($value, '--profile')) { if (str_contains($value, '--profile')) {
$_SERVER['COLLISION_PRINTER_PROFILE'] = 'true'; $_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 = Container::getInstance();
$container->add(TestSuite::class, $testSuite); $container->add(TestSuite::class, $testSuite);
$container->add(OutputInterface::class, $output); $container->add(OutputInterface::class, $output);
$container->add(InputInterface::class, $argv);
$argsToUnset = ['--test-directory', '--compact', '--profile', '--dirty']; $container->add(Container::class, $container);
foreach ($args as $key => $value) {
if (in_array($value, $argsToUnset)) {
unset($args[$key]);
}
}
$kernel = Kernel::boot(); $kernel = Kernel::boot();

View File

@ -23,6 +23,7 @@
"pestphp/pest-plugin": "^2.0.0", "pestphp/pest-plugin": "^2.0.0",
"phpunit/phpunit": "10.0.x-dev" "phpunit/phpunit": "10.0.x-dev"
}, },
"version": "2.x-dev",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Pest\\": "src/" "Pest\\": "src/"

View File

@ -24,6 +24,8 @@
<testsuites> <testsuites>
<testsuite name="default"> <testsuite name="default">
<directory suffix=".php">./tests</directory> <directory suffix=".php">./tests</directory>
<exclude>./tests/.snapshots</exclude>
<exclude>./tests/.tests</exclude>
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage> <coverage>

View File

@ -6,6 +6,7 @@ namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper; use Pest\Contracts\Bootstrapper;
use Pest\Subscribers; use Pest\Subscribers;
use Pest\Support\Container;
use PHPUnit\Event; use PHPUnit\Event;
use PHPUnit\Event\Subscriber; use PHPUnit\Event\Subscriber;
@ -25,16 +26,24 @@ final class BootSubscribers implements Bootstrapper
Subscribers\EnsureRetryRepositoryExists::class, Subscribers\EnsureRetryRepositoryExists::class,
Subscribers\EnsureErroredTestsAreRetryable::class, Subscribers\EnsureErroredTestsAreRetryable::class,
Subscribers\EnsureFailedTestsAreRetryable::class, Subscribers\EnsureFailedTestsAreRetryable::class,
Subscribers\EnsureTeamCityEnabled::class,
]; ];
public function __construct(
private readonly Container $container,
) {
}
/** /**
* Boots the Subscribers. * Boots the Subscribers.
*/ */
public function boot(): void public function boot(): void
{ {
foreach (self::SUBSCRIBERS as $subscriber) { foreach (self::SUBSCRIBERS as $subscriber) {
/** @var Subscriber $instance */
$instance = $this->container->get($subscriber);
Event\Facade::registerSubscriber( Event\Facade::registerSubscriber(
new $subscriber() $instance
); );
} }
} }

View 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;
}

View File

@ -10,7 +10,7 @@ use Pest\Factories\TestCaseMethodFactory;
/** /**
* @internal * @internal
*/ */
final class CoversNothing final class CoversNothing implements AddsAnnotation
{ {
/** /**
* Adds annotations regarding the "depends" feature. * Adds annotations regarding the "depends" feature.

View File

@ -10,7 +10,7 @@ use Pest\Support\Str;
/** /**
* @internal * @internal
*/ */
final class Depends final class Depends implements AddsAnnotation
{ {
/** /**
* Adds annotations regarding the "depends" feature. * Adds annotations regarding the "depends" feature.

View File

@ -9,7 +9,7 @@ use Pest\Factories\TestCaseMethodFactory;
/** /**
* @internal * @internal
*/ */
final class Groups final class Groups implements AddsAnnotation
{ {
/** /**
* Adds annotations regarding the "groups" feature. * Adds annotations regarding the "groups" feature.

View 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;
}
}

View File

@ -11,6 +11,7 @@ use Pest\Exceptions\DatasetMissing;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Exceptions\TestAlreadyExist; use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestDescriptionMissing; use Pest\Exceptions\TestDescriptionMissing;
use Pest\Factories\Annotations\AddsAnnotation;
use Pest\Factories\Concerns\HigherOrderable; use Pest\Factories\Concerns\HigherOrderable;
use Pest\Plugins\Environment; use Pest\Plugins\Environment;
use Pest\Support\Reflection; use Pest\Support\Reflection;
@ -29,12 +30,13 @@ final class TestCaseFactory
/** /**
* The list of annotations. * The list of annotations.
* *
* @var array<int, class-string> * @var array<int, class-string<AddsAnnotation>>
*/ */
private const ANNOTATIONS = [ private const ANNOTATIONS = [
Annotations\Depends::class, Annotations\Depends::class,
Annotations\Groups::class, Annotations\Groups::class,
Annotations\CoversNothing::class, Annotations\CoversNothing::class,
Annotations\TestDox::class,
]; ];
/** /**
@ -198,6 +200,10 @@ final class TestCaseFactory
use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite; use Pest\TestSuite as __PestTestSuite;
/**
* @testdox $filename
*/
$classAttributesCode $classAttributesCode
#[\AllowDynamicProperties] #[\AllowDynamicProperties]
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {

View File

@ -6,6 +6,7 @@ namespace Pest\Factories;
use Closure; use Closure;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Annotations\AddsAnnotation;
use Pest\Factories\Concerns\HigherOrderable; use Pest\Factories\Concerns\HigherOrderable;
use Pest\Plugins\Retry; use Pest\Plugins\Retry;
use Pest\Repositories\DatasetsRepository; use Pest\Repositories\DatasetsRepository;
@ -112,7 +113,7 @@ final class TestCaseMethodFactory
/** /**
* Creates a PHPUnit method as a string ready for evaluation. * 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 * @param array<int, class-string<\Pest\Factories\Attributes\Attribute>> $attributesToUse
*/ */
public function buildForEvaluation(string $classFQN, array $annotationsToUse, array $attributesToUse): string public function buildForEvaluation(string $classFQN, array $annotationsToUse, array $attributesToUse): string
@ -134,7 +135,6 @@ final class TestCaseMethodFactory
$attributes = []; $attributes = [];
foreach ($annotationsToUse as $annotation) { foreach ($annotationsToUse as $annotation) {
/** @phpstan-ignore-next-line */
$annotations = (new $annotation())->__invoke($this, $annotations); $annotations = (new $annotation())->__invoke($this, $annotations);
} }

View File

@ -73,9 +73,7 @@ final class Kernel
{ {
$argv = (new Plugins\Actions\CallsHandleArguments())->__invoke($argv); $argv = (new Plugins\Actions\CallsHandleArguments())->__invoke($argv);
$this->application->run( $this->application->run($argv, false);
$argv, false,
);
return (new CallsAddsOutput())->__invoke( return (new CallsAddsOutput())->__invoke(
Result::exitCode(), Result::exitCode(),

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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);
}
}

View 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);
}
}

View File

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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);
}
}

View File

@ -29,7 +29,7 @@ final class TestRepository
private array $uses = []; private array $uses = [];
/** /**
* @var array<int, TestCaseFilter> * @var array<int, TestCaseFilter>
*/ */
private array $filters = []; private array $filters = [];

View 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
);
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Support; namespace Pest\Support;
use Closure; use Closure;
use ReflectionProperty;
use Throwable; use Throwable;
/** /**
@ -37,32 +36,4 @@ final class ExceptionTrace
throw $throwable; 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);
}
} }

View File

@ -76,4 +76,9 @@ final class Str
return substr($subject, 0, $pos); return substr($subject, 0, $pos);
} }
public static function after(string $subject, string $search): string
{
return $search === '' ? $subject : array_reverse(explode($search, $subject, 2))[0];
}
} }

View File

@ -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

View File

@ -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

View File

@ -893,9 +893,10 @@
- visual snapshot of test suite on success - visual snapshot of test suite on success
WARN Tests\Visual\TeamCity 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 PASS Tests\Visual\Version
✓ visual snapshot of help command output ✓ visual snapshot of help command output
Tests: 4 incomplete, 4 todos, 17 skipped, 624 passed (1511 assertions) Tests: 4 incomplete, 4 todos, 18 skipped, 624 passed (1511 assertions)

26
tests/.tests/Failure.php Normal file
View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
it('can fail with comparison', function () {
expect(true)->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.");

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
it('can pass with comparison', function () {
expect(true)->toEqual(true);
});
test('can also pass', function () {
expect("string")->toBeString();
});

View File

@ -1,32 +1,42 @@
<?php <?php
use Pest\Logging\TeamCity; test('visual snapshot of team city', function (string $testFile) {
use PHPUnit\Framework\AssertionFailedError; $testsPath = dirname(__DIR__)."/.tests/$testFile";
use PHPUnit\Framework\TestResult;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Framework\Warning;
use PHPUnit\TextUI\DefaultResultPrinter;
beforeEach(function () { $snapshot = implode(DIRECTORY_SEPARATOR, [
file_put_contents(__DIR__.'/output.txt', ''); dirname(__DIR__),
}); '.snapshots',
"$testFile.inc",
]);
it('is can successfully call all public methods', function () { $output = function () use ($testsPath) {
$teamCity = new TeamCity(__DIR__.'/output.txt', false, DefaultResultPrinter::COLOR_ALWAYS); $process = (new Symfony\Component\Process\Process(
expect($teamCity::isPestTest($this))->toBeTrue(); ['php', 'bin/pest', '--teamcity', $testsPath],
$teamCity->startTestSuite(new TestSuite()); dirname(__DIR__, levels: 2),
$teamCity->startTest($this); [
$teamCity->addError($this, new Exception('Don\'t worry about this error. Its purposeful.'), 0); 'EXCLUDE' => 'integration',
$teamCity->addFailure($this, new AssertionFailedError('Don\'t worry about this error. Its purposeful.'), 0); 'REBUILD_SNAPSHOTS' => false,
$teamCity->addWarning($this, new Warning(), 0); 'PARATEST' => 0,
$teamCity->addIncompleteTest($this, new Exception(), 0); 'COLLISION_IGNORE_DURATION' => 'true',
$teamCity->addRiskyTest($this, new Exception(), 0); 'FLOW_ID' => '1234',
$teamCity->addSkippedTest($this, new Exception(), 0); ],
$teamCity->endTest($this, 0); ));
$teamCity->printResult(new TestResult());
$teamCity->endTestSuite(new TestSuite());
})->skip('Not supported yet.');
afterEach(function () { $process->run();
unlink(__DIR__.'/output.txt');
}); 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'));