diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0151ddd3..d0ca6965 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,11 +5,26 @@ on: ['push', 'pull_request'] jobs: ci: runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - php: ['7.3', '7.4', '8.0', '8.1'] + php: ['7.3', '7.4', '8.0'] dependency-version: [prefer-lowest, prefer-stable] + experimental: [false] + include: + - php: '8.1' + os: ubuntu-latest + experimental: true + dependency-version: prefer-stable + - php: '8.1' + os: macos-latest + experimental: true + dependency-version: prefer-stable + - php: '8.1' + os: windows-latest + experimental: true + dependency-version: prefer-stable name: PHP ${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b05a97d..b63a6eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [v1.13.0 (2021-07-28)](https://github.com/pestphp/pest/compare/v1.12.0...v1.13.0) +## [v1.13.0 (2021-08-02)](https://github.com/pestphp/pest/compare/v1.12.0...v1.13.0) ### Added +- A cleaner output when running the Pest runner in PhpStorm ([#350](https://github.com/pestphp/pest/pull/350)) - `toBeIn` expectation ([#363](https://github.com/pestphp/pest/pull/363)) ### Fixed diff --git a/composer.json b/composer.json index d3feddb8..9a3960f9 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "php": "^7.3 || ^8.0", "nunomaduro/collision": "^5.4.0", "pestphp/pest-plugin": "^1.0.0", - "phpunit/phpunit": "^9.3.7" + "phpunit/phpunit": "^9.5.5" }, "autoload": { "psr-4": { diff --git a/src/Actions/AddsDefaults.php b/src/Actions/AddsDefaults.php index 9a177a46..f16b8879 100644 --- a/src/Actions/AddsDefaults.php +++ b/src/Actions/AddsDefaults.php @@ -30,7 +30,7 @@ final class AddsDefaults } if ($arguments[self::PRINTER] === \PHPUnit\Util\Log\TeamCity::class) { - $arguments[self::PRINTER] = new TeamCity($arguments['verbose'] ?? false, $arguments['colors'] ?? DefaultResultPrinter::COLOR_ALWAYS); + $arguments[self::PRINTER] = new TeamCity(null, $arguments['verbose'] ?? false, $arguments['colors'] ?? DefaultResultPrinter::COLOR_ALWAYS); } // Load our junit logger instead. diff --git a/src/Concerns/Logging/WritesToConsole.php b/src/Concerns/Logging/WritesToConsole.php new file mode 100644 index 00000000..a2965bcb --- /dev/null +++ b/src/Concerns/Logging/WritesToConsole.php @@ -0,0 +1,33 @@ +writePestTestOutput($message, 'fg-green, bold', '✓'); + } + + private function writeError(string $message): void + { + $this->writePestTestOutput($message, 'fg-red, bold', '⨯'); + } + + private function writeWarning(string $message): void + { + $this->writePestTestOutput($message, 'fg-yellow, bold', '-'); + } + + private function writePestTestOutput(string $message, string $color, string $symbol): void + { + $this->writeWithColor($color, "$symbol ", false); + $this->write($message); + $this->writeNewLine(); + } +} diff --git a/src/Logging/TeamCity.php b/src/Logging/TeamCity.php index 14704aab..a9363b43 100644 --- a/src/Logging/TeamCity.php +++ b/src/Logging/TeamCity.php @@ -5,27 +5,34 @@ declare(strict_types=1); namespace Pest\Logging; use function getmypid; +use Pest\Concerns\Logging\WritesToConsole; use Pest\Concerns\Testable; +use Pest\Support\ExceptionTrace; +use function Pest\version; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Test; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestResult; use PHPUnit\Framework\TestSuite; use PHPUnit\Framework\Warning; -use PHPUnit\Runner\PhptTestCase; use PHPUnit\TextUI\DefaultResultPrinter; use function round; use function str_replace; +use function strlen; 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; @@ -36,154 +43,105 @@ final class TeamCity extends DefaultResultPrinter /** @var \PHPUnit\Util\Log\TeamCity */ private $phpunitTeamCity; - public function __construct(bool $verbose, string $colors) + /** + * @param resource|string|null $out + */ + public function __construct($out, bool $verbose, string $colors) { - parent::__construct(null, $verbose, $colors, false, 80, false); - $this->phpunitTeamCity = new \PHPUnit\Util\Log\TeamCity( - null, - $verbose, - $colors, - false, - 80, - false - ); + parent::__construct($out, $verbose, $colors); + $this->phpunitTeamCity = new \PHPUnit\Util\Log\TeamCity($out, $verbose, $colors); + + $this->logo(); + } + + private function logo(): void + { + $this->writeNewLine(); + $this->write('Pest ' . version()); + $this->writeNewLine(); } public function printResult(TestResult $result): void { - $this->printHeader($result); - $this->printFooter($result); + $this->write('Tests: '); + + $results = [ + 'failed' => ['count' => $result->errorCount() + $result->failureCount(), 'color' => 'fg-red'], + 'skipped' => ['count' => $result->skippedCount(), 'color' => 'fg-yellow'], + 'warned' => ['count' => $result->warningCount(), 'color' => 'fg-yellow'], + 'risked' => ['count' => $result->riskyCount(), 'color' => 'fg-yellow'], + 'incomplete' => ['count' => $result->notImplementedCount(), 'color' => 'fg-yellow'], + 'passed' => ['count' => $this->successfulTestCount($result), 'color' => 'fg-green'], + ]; + + $filteredResults = array_filter($results, function ($item): bool { + return $item['count'] > 0; + }); + + foreach ($filteredResults as $key => $info) { + $this->writeWithColor($info['color'], $info['count'] . " $key", false); + + if ($key !== array_reverse(array_keys($filteredResults))[0]) { + $this->write(', '); + } + } + + $this->writeNewLine(); + $this->write("Assertions: $this->numAssertions"); + + $this->writeNewLine(); + $this->write("Time: {$result->time()}s"); + + $this->writeNewLine(); + } + + private function successfulTestCount(TestResult $result): int + { + return $result->count() + - $result->failureCount() + - $result->errorCount() + - $result->skippedCount() + - $result->warningCount() + - $result->notImplementedCount() + - $result->riskyCount(); } /** @phpstan-ignore-next-line */ public function startTestSuite(TestSuite $suite): void { + $suiteName = $suite->getName(); + + if (static::isCompoundTestSuite($suite)) { + $this->writeWithColor('bold', ' ' . $suiteName); + } elseif (static::isPestTestSuite($suite)) { + $this->writeWithColor('fg-white, bold', ' ' . substr_replace($suiteName, '', 0, 2) . ' '); + } else { + $this->writeWithColor('fg-white, bold', ' ' . $suiteName); + } + + $this->writeNewLine(); + $this->flowId = (int) getmypid(); if (!$this->isSummaryTestCountPrinted) { - $this->printEvent( - 'testCount', - ['count' => $suite->count()] - ); + $this->printEvent(self::TEST_COUNT, [ + 'count' => $suite->count(), + ]); $this->isSummaryTestCountPrinted = true; } - $suiteName = $suite->getName(); - - if (file_exists($suiteName) || !method_exists($suiteName, '__getFileName')) { - $this->printEvent( - self::TEST_SUITE_STARTED, [ - self::NAME => $suiteName, - self::LOCATION_HINT => self::PROTOCOL . $suiteName, - ]); - - return; - } - - $fileName = $suiteName::__getFileName(); - - $this->printEvent( - self::TEST_SUITE_STARTED, [ - self::NAME => substr($suiteName, 2), - self::LOCATION_HINT => self::PROTOCOL . $fileName, + $this->printEvent(self::TEST_SUITE_STARTED, [ + self::NAME => static::isCompoundTestSuite($suite) ? $suiteName : substr($suiteName, 2), + self::LOCATION_HINT => self::PROTOCOL . (static::isCompoundTestSuite($suite) ? $suiteName : $suiteName::__getFileName()), ]); } - /** @phpstan-ignore-next-line */ - public function endTestSuite(TestSuite $suite): void - { - $suiteName = $suite->getName(); - - if (file_exists($suiteName) || !method_exists($suiteName, '__getFileName')) { - $this->printEvent( - self::TEST_SUITE_FINISHED, [ - self::NAME => $suiteName, - self::LOCATION_HINT => self::PROTOCOL . $suiteName, - ]); - - return; - } - - $this->printEvent( - self::TEST_SUITE_FINISHED, [ - self::NAME => substr($suiteName, 2), - ]); - } - - /** - * @param Test|Testable $test - */ - public function startTest(Test $test): void - { - if (!TeamCity::isPestTest($test)) { - $this->phpunitTeamCity->startTest($test); - - return; - } - - $this->printEvent('testStarted', [ - self::NAME => $test->getName(), - // @phpstan-ignore-next-line - self::LOCATION_HINT => self::PROTOCOL . $test->toString(), - ]); - } - - /** - * @param Test|Testable $test - */ - public function endTest(Test $test, float $time): void - { - if (!TeamCity::isPestTest($test)) { - $this->phpunitTeamCity->endTest($test, $time); - - return; - } - - if ($test instanceof TestCase) { - $this->numAssertions += $test->getNumAssertions(); - } elseif ($test instanceof PhptTestCase) { - $this->numAssertions++; - } - - $this->printEvent('testFinished', [ - self::NAME => $test->getName(), - self::DURATION => self::toMilliseconds($time), - ]); - } - - /** - * @param Test|Testable $test - */ - public function addError(Test $test, Throwable $t, float $time): void - { - $this->phpunitTeamCity->addError($test, $t, $time); - } - - /** - * @phpstan-ignore-next-line - * - * @param Test|Testable $test - */ - public function addWarning(Test $test, Warning $e, float $time): void - { - $this->phpunitTeamCity->addWarning($test, $e, $time); - } - - public function addFailure(Test $test, AssertionFailedError $e, float $time): void - { - $this->phpunitTeamCity->addFailure($test, $e, $time); - } - - protected function writeProgress(string $progress): void - { - } - /** * @param array $params */ private function printEvent(string $eventName, array $params = []): void { - $this->write("\n##teamcity[{$eventName}"); + $this->write("##teamcity[{$eventName}"); if ($this->flowId !== 0) { $params['flowId'] = $this->flowId; @@ -206,9 +164,56 @@ final class TeamCity extends DefaultResultPrinter ); } - private static function toMilliseconds(float $time): int + /** @phpstan-ignore-next-line */ + public function endTestSuite(TestSuite $suite): void { - return (int) round($time * 1000); + $suiteName = $suite->getName(); + + $this->writeNewLine(); + $this->writeNewLine(); + + $this->printEvent(self::TEST_SUITE_FINISHED, [ + self::NAME => static::isCompoundTestSuite($suite) ? $suiteName : substr($suiteName, 2), + self::LOCATION_HINT => self::PROTOCOL . (static::isCompoundTestSuite($suite) ? $suiteName : $suiteName::__getFileName()), + ]); + } + + /** + * @param Test|Testable $test + */ + public function startTest(Test $test): void + { + if (!TeamCity::isPestTest($test)) { + $this->phpunitTeamCity->startTest($test); + + return; + } + + $this->printEvent(self::TEST_STARTED, [ + self::NAME => $test->getName(), + // @phpstan-ignore-next-line + self::LOCATION_HINT => self::PROTOCOL . $test->toString(), + ]); + } + + /** + * Verify that the given test suite is a valid Pest suite. + * + * @param TestSuite $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 $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 @@ -218,4 +223,75 @@ final class TeamCity extends DefaultResultPrinter 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), + ]); + + if (!$this->lastTestFailed) { + $this->writeSuccess($test->getName()); + } + + $this->numAssertions += $test instanceof TestCase ? $test->getNumAssertions() : 1; + $this->lastTestFailed = false; + } + + private static function toMilliseconds(float $time): int + { + return (int) round($time * 1000); + } + + public function addError(Test $test, Throwable $t, float $time): void + { + $this->markAsFailure($t); + $this->writeError($test->getName()); + $this->phpunitTeamCity->addError($test, $t, $time); + } + + public function addFailure(Test $test, AssertionFailedError $e, float $time): void + { + $this->markAsFailure($e); + $this->writeError($test->getName()); + $this->phpunitTeamCity->addFailure($test, $e, $time); + } + + public function addWarning(Test $test, Warning $e, float $time): void + { + $this->markAsFailure($e); + $this->writeWarning($test->getName()); + $this->phpunitTeamCity->addWarning($test, $e, $time); + } + + public function addIncompleteTest(Test $test, Throwable $t, float $time): void + { + $this->markAsFailure($t); + $this->writeWarning($test->getName()); + $this->phpunitTeamCity->addIncompleteTest($test, $t, $time); + } + + public function addRiskyTest(Test $test, Throwable $t, float $time): void + { + $this->markAsFailure($t); + $this->writeWarning($test->getName()); + $this->phpunitTeamCity->addRiskyTest($test, $t, $time); + } + + public function addSkippedTest(Test $test, Throwable $t, float $time): void + { + $this->markAsFailure($t); + $this->writeWarning($test->getName()); + $this->phpunitTeamCity->addSkippedTest($test, $t, $time); + } + + private function markAsFailure(Throwable $t): void + { + $this->lastTestFailed = true; + ExceptionTrace::removePestReferences($t); + } } diff --git a/src/Support/ExceptionTrace.php b/src/Support/ExceptionTrace.php index 014b88df..ec17afc8 100644 --- a/src/Support/ExceptionTrace.php +++ b/src/Support/ExceptionTrace.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Pest\Support; use Closure; +use ReflectionProperty; use Throwable; /** @@ -36,4 +37,30 @@ 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); + $trace = $property->getValue($t); + + $cleanedTrace = []; + foreach ($trace as $item) { + if (key_exists('file', $item) && mb_strpos($item['file'], 'vendor/pestphp/pest/') > 0) { + continue; + } + + $cleanedTrace[] = $item; + } + + $property->setValue($t, $cleanedTrace); + } } diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 4597d4e8..9790bb20 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -96,11 +96,6 @@ ✓ more than two datasets with (2) / (4) / (5) ✓ more than two datasets with (2) / (4) / (6) ✓ more than two datasets did the job right - ✓ it can resolve a dataset after the test case is available with (Closure Object (...)) - ✓ it can resolve a dataset after the test case is available with shared yield sets with (Closure Object (...)) #1 - ✓ it can resolve a dataset after the test case is available with shared yield sets with (Closure Object (...)) #2 - ✓ it can resolve a dataset after the test case is available with shared array sets with (Closure Object (...)) #1 - ✓ it can resolve a dataset after the test case is available with shared array sets with (Closure Object (...)) #2 PASS Tests\Features\Exceptions ✓ it gives access the the underlying expectException @@ -590,6 +585,9 @@ PASS Tests\Visual\Help ✓ visual snapshot of help command output + PASS Tests\Visual\JUnit + ✓ it is can successfully call all public methods + PASS Tests\Visual\SingleTestOrDirectory ✓ allows to run a single test ✓ allows to run a directory @@ -599,6 +597,9 @@ WARN Tests\Visual\Success - visual snapshot of test suite on success + PASS Tests\Visual\TeamCity + ✓ it is can successfully call all public methods + PASS Tests\Features\Depends ✓ first ✓ second @@ -613,5 +614,5 @@ ✓ it is a test ✓ it uses correct parent class - Tests: 4 incompleted, 9 skipped, 393 passed + Tests: 4 incompleted, 9 skipped, 390 passed \ No newline at end of file diff --git a/tests/Visual/JUnit.php b/tests/Visual/JUnit.php new file mode 100644 index 00000000..a12d561f --- /dev/null +++ b/tests/Visual/JUnit.php @@ -0,0 +1,29 @@ +startTestSuite(new TestSuite()); + $junit->startTest($this); + $junit->addError($this, new Exception(), 0); + $junit->addFailure($this, new AssertionFailedError(), 0); + $junit->addWarning($this, new Warning(), 0); + $junit->addIncompleteTest($this, new Exception(), 0); + $junit->addRiskyTest($this, new Exception(), 0); + $junit->addSkippedTest($this, new Exception(), 0); + $junit->endTest($this, 0); + $junit->endTestSuite(new TestSuite()); + $this->expectNotToPerformAssertions(); +}); + +afterEach(function () { + unlink(__DIR__ . '/junit.html'); +}); diff --git a/tests/Visual/TeamCity.php b/tests/Visual/TeamCity.php new file mode 100644 index 00000000..1681f1ac --- /dev/null +++ b/tests/Visual/TeamCity.php @@ -0,0 +1,32 @@ +toBeTrue(); + $teamCity->startTestSuite(new TestSuite()); + $teamCity->startTest($this); + $teamCity->addError($this, new Exception('Don\'t worry about this error. Its purposeful.'), 0); + $teamCity->addFailure($this, new AssertionFailedError('Don\'t worry about this error. Its purposeful.'), 0); + $teamCity->addWarning($this, new Warning(), 0); + $teamCity->addIncompleteTest($this, new Exception(), 0); + $teamCity->addRiskyTest($this, new Exception(), 0); + $teamCity->addSkippedTest($this, new Exception(), 0); + $teamCity->endTest($this, 0); + $teamCity->printResult(new TestResult()); + $teamCity->endTestSuite(new TestSuite()); +}); + +afterEach(function () { + unlink(__DIR__ . '/output.txt'); +});