Skip to content

Instantly share code, notes, and snippets.

@christophrumpel
Last active August 26, 2022 09:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save christophrumpel/170b66b3e7ab690a3aa59d52622275f3 to your computer and use it in GitHub Desktop.
Save christophrumpel/170b66b3e7ab690a3aa59d52622275f3 to your computer and use it in GitHub Desktop.
TeamCity
<?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