Last active
August 26, 2022 09:13
-
-
Save christophrumpel/170b66b3e7ab690a3aa59d52622275f3 to your computer and use it in GitHub Desktop.
TeamCity
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
namespace Pest\Logging; | |
use Illuminate\Console\BufferedConsoleOutput; | |
use NunoMaduro\Collision\Adapters\Phpunit\State; | |
use NunoMaduro\Collision\Adapters\Phpunit\Style; | |
use NunoMaduro\Collision\Adapters\Phpunit\TestResult as CollisionTestResult; | |
use NunoMaduro\Collision\Adapters\Phpunit\Timer; | |
use NunoMaduro\Collision\Exceptions\ShouldNotHappen; | |
use Pest\Concerns\Logging\WritesToConsole; | |
use Pest\Concerns\Testable; | |
use PHPUnit\Framework\AssertionFailedError; | |
use PHPUnit\Framework\ExpectationFailedException; | |
use PHPUnit\Framework\Test; | |
use PHPUnit\Framework\TestCase; | |
use PHPUnit\Framework\TestResult; | |
use PHPUnit\Framework\TestSuite; | |
use PHPUnit\Framework\Warning; | |
use PHPUnit\TextUI\DefaultResultPrinter; | |
use ReflectionObject; | |
use SebastianBergmann\Comparator\ComparisonFailure; | |
use function round; | |
use function str_replace; | |
use function strlen; | |
use Symfony\Component\Console\Output\ConsoleOutput; | |
use Symfony\Component\Console\Output\OutputInterface; | |
use Throwable; | |
final class TeamCity extends DefaultResultPrinter | |
{ | |
use WritesToConsole; | |
private const PROTOCOL = 'pest_qn://'; | |
private const NAME = 'name'; | |
private const LOCATION_HINT = 'locationHint'; | |
private const DURATION = 'duration'; | |
private const TEST_SUITE_STARTED = 'testSuiteStarted'; | |
private const TEST_SUITE_FINISHED = 'testSuiteFinished'; | |
private const TEST_COUNT = 'testCount'; | |
private const TEST_STARTED = 'testStarted'; | |
private const TEST_FINISHED = 'testFinished'; | |
/** @var int */ | |
private $flowId; | |
/** @var bool */ | |
private $isSummaryTestCountPrinted = false; | |
/** @var \PHPUnit\Util\Log\TeamCity */ | |
private $phpunitTeamCity; | |
/** @var Style */ | |
private $style; | |
/** @var Style */ | |
private $bufferedStyle; | |
/** @var BufferedConsoleOutput */ | |
private $bufferedOutput; | |
/** @var State */ | |
private $state; | |
/** | |
* Holds the duration time of the test suite. | |
* | |
* @var Timer | |
*/ | |
private $timer; | |
/** | |
* If the test suite has failed. | |
* | |
* @var bool | |
*/ | |
private $failed = false; | |
private $emptycurrentState; | |
/** | |
* @param resource|string|null $out | |
*/ | |
public function __construct($out, bool $verbose, string $colors) | |
{ | |
parent::__construct($out, $verbose, $colors); | |
$this->timer = Timer::start(); | |
$output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true); | |
$this->style = new Style($output); | |
$this->bufferedOutput = new class extends BufferedConsoleOutput { | |
protected function doWrite(string $message, bool $newline) | |
{ | |
$this->buffer .= $message; | |
if ($newline) { | |
$this->buffer .= \PHP_EOL; | |
} | |
} | |
}; | |
$this->bufferedStyle = new Style($this->bufferedOutput); | |
$this->phpunitTeamCity = new \PHPUnit\Util\Log\TeamCity($out, $verbose, $colors); | |
$dummyTest = new class() extends TestCase { | |
}; | |
$this->state = State::from($dummyTest); | |
$this->emptycurrentState = State::from($dummyTest); | |
} | |
public function startTestSuite(TestSuite $suite): void | |
{ | |
if ($this->state->suiteTotalTests === null) { | |
$this->state->suiteTotalTests = $suite->count(); | |
} | |
$suiteName = $suite->getName(); | |
$this->printEvent(self::TEST_SUITE_STARTED, [ | |
self::NAME => TeamCity::isCompoundTestSuite($suite) ? $suiteName : substr($suiteName, 2), | |
self::LOCATION_HINT => self::PROTOCOL . (TeamCity::isCompoundTestSuite($suite) ? $suiteName : $suiteName::__getFileName()), | |
]); | |
} | |
/** | |
* @param array<string, string|int> $params | |
*/ | |
private function printEvent(string $eventName, array $params = []): void | |
{ | |
$this->style->write("##teamcity[{$eventName}"); | |
if ($this->flowId !== 0) { | |
$params['flowId'] = $this->flowId; | |
} | |
foreach ($params as $key => $value) { | |
$escapedValue = self::escapeValue((string) $value); | |
$this->style->write(" {$key}='{$escapedValue}'"); | |
} | |
$this->style->write("]\n"); | |
} | |
private static function escapeValue(string $text): string | |
{ | |
return str_replace( | |
['|', "'", "\n", "\r", ']', '['], | |
['||', "|'", '|n', '|r', '|]', '|['], | |
$text | |
); | |
} | |
public function endTestSuite(TestSuite $suite): void | |
{ | |
$suiteName = $suite->getName(); | |
$this->printEvent(self::TEST_SUITE_FINISHED, [ | |
self::NAME => TeamCity::isCompoundTestSuite($suite) ? $suiteName : substr($suiteName, 2), | |
self::LOCATION_HINT => self::PROTOCOL . (TeamCity::isCompoundTestSuite($suite) ? $suiteName : $suiteName::__getFileName()), | |
]); | |
} | |
/** | |
* @param Test|Testable $test | |
*/ | |
public function startTest(Test $test): void | |
{ | |
if (!TeamCity::isPestTest($test)) { | |
$this->phpunitTeamCity->startTest($test); | |
return; | |
} | |
$testCase = $this->testCaseFromTest($test); | |
// Let's check first if the testCase is over. | |
if ($this->state->testCaseHasChanged($testCase)) { | |
$this->style->writeCurrentTestCaseSummary($this->state); | |
$this->state->moveTo($testCase); | |
$this->emptycurrentState->moveTo($testCase); | |
} | |
$this->printEvent(self::TEST_STARTED, [ | |
self::NAME => $testCase->getName(), | |
// @phpstan-ignore-next-line | |
self::LOCATION_HINT => self::PROTOCOL . $testCase->toString(), | |
]); | |
} | |
/** | |
* Verify that the given test suite is a valid Pest suite. | |
* | |
* @param TestSuite<Test> $suite | |
*/ | |
private static function isPestTestSuite(TestSuite $suite): bool | |
{ | |
return strncmp($suite->getName(), 'P\\', strlen('P\\')) === 0; | |
} | |
/** | |
* Determine if the test suite is made up of multiple smaller test suites. | |
* | |
* @param TestSuite<Test> $suite | |
*/ | |
private static function isCompoundTestSuite(TestSuite $suite): bool | |
{ | |
return file_exists($suite->getName()) || !method_exists($suite->getName(), '__getFileName'); | |
} | |
public static function isPestTest(Test $test): bool | |
{ | |
/** @var array<string, string> $uses */ | |
$uses = class_uses($test); | |
return in_array(Testable::class, $uses, true); | |
} | |
/** | |
* @param Test|Testable $test | |
*/ | |
public function endTest(Test $test, float $time): void | |
{ | |
$this->printEvent(self::TEST_FINISHED, [ | |
self::NAME => $test->getName(), | |
self::DURATION => self::toMilliseconds($time), | |
]); | |
$testCase = $this->testCaseFromTest($test); | |
if (!$this->state->existsInTestCase($testCase)) { | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::PASS)); | |
} | |
if ($testCase->getTestResultObject() instanceof TestResult | |
&& !$testCase->getTestResultObject()->isStrictAboutOutputDuringTests() | |
&& !$testCase->hasExpectationOnOutput()) { | |
$this->style->write($testCase->getActualOutput()); | |
} | |
} | |
private static function toMilliseconds(float $time): int | |
{ | |
return (int) round($time * 1000); | |
} | |
public function addError(Test $test, Throwable $throwable, float $time): void | |
{ | |
$this->failed = true; | |
$testCase = $this->testCaseFromTest($test); | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::FAIL, $throwable)); | |
$currentTestState = clone $this->emptycurrentState; | |
$currentTestState->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::FAIL, $throwable)); | |
$this->style->writeCurrentTestCaseSummary($currentTestState); | |
$this->bufferedStyle->writeError($throwable); | |
$output = $this->bufferedOutput->fetch(); | |
[$message, $details] = explode("\n", $output, 2); | |
$this->printEvent( | |
'testFailed', | |
[ | |
'name' => $test->getName(), | |
'message' => $message, | |
'details' => $details, | |
'duration' => self::toMilliseconds($time), | |
] | |
); | |
} | |
public function addFailure(Test $test, AssertionFailedError $error, float $time): void | |
{ | |
$this->failed = true; | |
$testCase = $this->testCaseFromTest($test); | |
$reflector = new ReflectionObject($error); | |
if ($reflector->hasProperty('message')) { | |
$message = trim((string) preg_replace("/\r|\n/", "\n ", $error->getMessage())); | |
$property = $reflector->getProperty('message'); | |
$property->setAccessible(true); | |
$property->setValue($error, $message); | |
} | |
$this->bufferedStyle->writeError($error); | |
$output = $this->bufferedOutput->fetch(); | |
[$message, $details] = explode("\n", $output, 2); | |
// Taken from vendor/phpunit/phpunit/src/Util/Log/TeamCity.php:104:126 | |
// Added to handle comparisonFailure type events. | |
$parameters = []; | |
if ($error instanceof ExpectationFailedException) { | |
$comparisonFailure = $error->getComparisonFailure(); | |
if ($comparisonFailure instanceof ComparisonFailure) { | |
$expectedString = $comparisonFailure->getExpectedAsString(); | |
if ($expectedString === null || empty($expectedString)) { | |
$expectedString = self::getPrimitiveValueAsString($comparisonFailure->getExpected()); | |
} | |
$actualString = $comparisonFailure->getActualAsString(); | |
if ($actualString === null || empty($actualString)) { | |
$actualString = self::getPrimitiveValueAsString($comparisonFailure->getActual()); | |
} | |
if ($actualString !== null && $expectedString !== null) { | |
$parameters['type'] = 'comparisonFailure'; | |
$parameters['actual'] = $actualString; | |
$parameters['expected'] = $expectedString; | |
} | |
} | |
} | |
$this->printEvent('testFailed', array_merge([ | |
'name' => $testCase->getName(), | |
'message' => trim($message), | |
'duration' => self::toMilliseconds($time), | |
'details' => $details, | |
], $parameters)); | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::FAIL, $error)); | |
$currentTestState = clone $this->emptycurrentState; | |
$currentTestState->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::FAIL, $error)); | |
$this->style->writeCurrentTestCaseSummary($currentTestState); | |
} | |
public function addWarning(Test $test, Warning $warning, float $time): void | |
{ | |
$testCase = $this->testCaseFromTest($test); | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::WARN, $warning)); | |
$this->phpunitTeamCity->addWarning($test, $warning, $time); | |
} | |
public function addIncompleteTest(Test $test, Throwable $throwable, float $time): void | |
{ | |
$testCase = $this->testCaseFromTest($test); | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::INCOMPLETE, $throwable)); | |
$this->phpunitTeamCity->addIncompleteTest($test, $throwable, $time); | |
} | |
public function addRiskyTest(Test $test, Throwable $throwable, float $time): void | |
{ | |
$testCase = $this->testCaseFromTest($test); | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::RISKY, $throwable)); | |
$this->phpunitTeamCity->addRiskyTest($test, $throwable, $time); | |
} | |
public function addSkippedTest(Test $test, Throwable $throwable, float $time): void | |
{ | |
$testCase = $this->testCaseFromTest($test); | |
$this->state->add(CollisionTestResult::fromTestCase($testCase, CollisionTestResult::SKIPPED, $throwable)); | |
$this->phpunitTeamCity->printIgnoredTest($test->getName(), $throwable, $time); | |
} | |
/** | |
* Intentionally left blank as we output things on events of the listener. | |
*/ | |
public function write(string $buffer): void | |
{ | |
} | |
protected function writeWithColor(string $color, string $buffer, bool $lf = true): void | |
{ | |
$this->style->write($this->colorizeTextBox($color, $buffer)); | |
if ($lf) { | |
$this->style->write(PHP_EOL); | |
} | |
} | |
public function printResult(TestResult $result): void | |
{ | |
if ($result->count() === 0) { | |
$this->style->writeWarning('No tests executed!'); | |
} | |
$this->style->writeCurrentTestCaseSummary($this->state); | |
if ($this->failed) { | |
$onFailure = $this->state->suiteTotalTests !== $this->state->testSuiteTestsCount(); | |
// $this->style->writeErrorsSummary($this->state, $onFailure); | |
} | |
$this->style->writeRecap($this->state, $this->timer); | |
} | |
/** | |
* Returns a test case from the given test. | |
* | |
* Note: This printer is do not work with normal Test classes - only | |
* with Test Case classes. Please report an issue if you think | |
* this should work any other way. | |
*/ | |
private function testCaseFromTest(Test $testCase): TestCase | |
{ | |
if (!$testCase instanceof TestCase) { | |
throw new ShouldNotHappen(); | |
} | |
return $testCase; | |
} | |
private static function getPrimitiveValueAsString($value): ?string | |
{ | |
if ($value === null) { | |
return 'null'; | |
} | |
if (is_bool($value)) { | |
return $value ? 'true' : 'false'; | |
} | |
if (is_scalar($value)) { | |
return print_r($value, true); | |
} | |
return null; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment