Upgrade framework

This commit is contained in:
2023-11-14 16:54:35 +01:00
parent 1648a5cd42
commit 4fcf6fffcc
10548 changed files with 693138 additions and 466698 deletions

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use Illuminate\Support\ServiceProvider;
use NunoMaduro\Collision\Adapters\Laravel\Commands\TestCommand;
use NunoMaduro\Collision\Contracts\Provider as ProviderContract;
use NunoMaduro\Collision\Handler;
use NunoMaduro\Collision\Provider;
use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository;
use NunoMaduro\Collision\Writer;
/**
* @internal
*
* @final
*/
class CollisionServiceProvider extends ServiceProvider
{
/**
* {@inheritdoc}
*
* @var bool
*/
protected $defer = true;
/**
* Boots application services.
*
* @return void
*/
public function boot()
{
$this->commands([
TestCommand::class,
]);
}
/**
* {@inheritdoc}
*/
public function register()
{
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
$this->app->bind(ProviderContract::class, function () {
// @phpstan-ignore-next-line
if ($this->app->has(\Spatie\Ignition\Contracts\SolutionProviderRepository::class)) {
/** @var \Spatie\Ignition\Contracts\SolutionProviderRepository $solutionProviderRepository */
$solutionProviderRepository = $this->app->get(\Spatie\Ignition\Contracts\SolutionProviderRepository::class);
$solutionsRepository = new IgnitionSolutionsRepository($solutionProviderRepository);
} else {
$solutionsRepository = new NullSolutionsRepository();
}
$writer = new Writer($solutionsRepository);
$handler = new Handler($writer);
return new Provider(null, $handler);
});
/** @var \Illuminate\Contracts\Debug\ExceptionHandler $appExceptionHandler */
$appExceptionHandler = $this->app->make(ExceptionHandlerContract::class);
$this->app->singleton(
ExceptionHandlerContract::class,
function ($app) use ($appExceptionHandler) {
return new ExceptionHandler($app, $appExceptionHandler);
}
);
}
}
/**
* {@inheritdoc}
*/
public function provides()
{
return [ProviderContract::class];
}
}

View File

@@ -0,0 +1,390 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Commands;
use Dotenv\Exception\InvalidPathException;
use Dotenv\Parser\Parser;
use Dotenv\Store\StoreBuilder;
use Illuminate\Console\Command;
use Illuminate\Support\Env;
use Illuminate\Support\Str;
use NunoMaduro\Collision\Adapters\Laravel\Exceptions\RequirementsException;
use NunoMaduro\Collision\Coverage;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Process;
/**
* @internal
*
* @final
*/
class TestCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test
{--without-tty : Disable output to TTY}
{--coverage : Indicates whether code coverage information should be collected}
{--min= : Indicates the minimum threshold enforcement for code coverage}
{--p|parallel : Indicates if the tests should run in parallel}
{--recreate-databases : Indicates if the test databases should be re-created}
{--drop-databases : Indicates if the test databases should be dropped}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run the application tests';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->ignoreValidationErrors();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$phpunitVersion = \PHPUnit\Runner\Version::id();
if ((int) $phpunitVersion[0] === 1) {
throw new RequirementsException('Running PHPUnit v10 or Pest v2 requires Collision ^7.0.');
}
if ((int) $phpunitVersion[0] < 9) {
throw new RequirementsException('Running Collision ^5.0 artisan test command requires at least PHPUnit ^9.0.');
}
// @phpstan-ignore-next-line
if ((int) \Illuminate\Foundation\Application::VERSION[0] < 8) {
throw new RequirementsException('Running Collision ^5.0 artisan test command requires at least Laravel ^8.0.');
}
if ($this->option('coverage') && ! Coverage::isAvailable()) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> ERROR </> Code coverage driver not available.%s</>",
Coverage::usingXdebug()
? " Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?"
: ''
));
$this->newLine();
return 1;
}
if ($this->option('parallel') && ! $this->isParallelDependenciesInstalled()) {
if (! $this->confirm('Running tests in parallel requires "brianium/paratest". Do you wish to install it as a dev dependency?')) {
return 1;
}
$this->installParallelDependencies();
}
$options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2);
$this->clearEnv();
$parallel = $this->option('parallel');
$process = (new Process(array_merge(
// Binary ...
$this->binary(),
// Arguments ...
$parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options)
),
null,
// Envs ...
$parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(),
))->setTimeout(null);
try {
$process->setTty(! $this->option('without-tty'));
} catch (RuntimeException $e) {
$this->output->writeln('Warning: '.$e->getMessage());
}
$exitCode = 1;
try {
$exitCode = $process->run(function ($type, $line) {
$this->output->write($line);
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
}
if ($exitCode === 0 && $this->option('coverage')) {
if (! $this->usingPest() && $this->option('parallel')) {
$this->newLine();
}
$coverage = Coverage::report($this->output);
$exitCode = (int) ($coverage < $this->option('min'));
if ($exitCode === 1) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.",
number_format($coverage, 1),
number_format((float) $this->option('min'), 1)
));
}
}
$this->newLine();
return $exitCode;
}
/**
* Get the PHP binary to execute.
*
* @return array
*/
protected function binary()
{
if ($this->usingPest()) {
$command = $this->option('parallel') ? ['vendor/pestphp/pest/bin/pest', '--parallel'] : ['vendor/pestphp/pest/bin/pest'];
} else {
$command = $this->option('parallel') ? ['vendor/brianium/paratest/bin/paratest'] : ['vendor/phpunit/phpunit/phpunit'];
}
if ('phpdbg' === PHP_SAPI) {
return array_merge([PHP_BINARY, '-qrr'], $command);
}
return array_merge([PHP_BINARY], $command);
}
/**
* Gets the common arguments of PHPUnit and Pest.
*
* @return array
*/
protected function commonArguments()
{
$arguments = [];
if ($this->option('coverage')) {
$arguments[] = '--coverage-php';
$arguments[] = Coverage::getPath();
}
return $arguments;
}
/**
* Determines if Pest is being used.
*
* @return bool
*/
protected function usingPest()
{
return class_exists(\Pest\Laravel\PestServiceProvider::class);
}
/**
* Get the array of arguments for running PHPUnit.
*
* @param array $options
* @return array
*/
protected function phpunitArguments($options)
{
$options = array_merge(['--printer=NunoMaduro\\Collision\\Adapters\\Phpunit\\Printer'], $options);
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '-q'
&& $option != '--quiet'
&& $option != '--coverage'
&& ! Str::startsWith($option, '--min');
}));
if (! file_exists($file = base_path('phpunit.xml'))) {
$file = base_path('phpunit.xml.dist');
}
return array_merge($this->commonArguments(), ["--configuration=$file"], $options);
}
/**
* Get the array of arguments for running Paratest.
*
* @param array $options
* @return array
*/
protected function paratestArguments($options)
{
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '--coverage'
&& $option != '-q'
&& $option != '--quiet'
&& ! Str::startsWith($option, '--min')
&& ! Str::startsWith($option, '-p')
&& ! Str::startsWith($option, '--parallel')
&& ! Str::startsWith($option, '--recreate-databases')
&& ! Str::startsWith($option, '--drop-databases');
}));
if (! file_exists($file = base_path('phpunit.xml'))) {
$file = base_path('phpunit.xml.dist');
}
return array_merge($this->commonArguments(), [
"--configuration=$file",
"--runner=\Illuminate\Testing\ParallelRunner",
], $options);
}
/**
* Get the array of environment variables for running PHPUnit.
*
* @return array
*/
protected function phpunitEnvironmentVariables()
{
return [];
}
/**
* Get the array of environment variables for running Paratest.
*
* @return array
*/
protected function paratestEnvironmentVariables()
{
return [
'LARAVEL_PARALLEL_TESTING' => 1,
'LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'),
'LARAVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'),
];
}
/**
* Clears any set Environment variables set by Laravel if the --env option is empty.
*
* @return void
*/
protected function clearEnv()
{
if (! $this->option('env')) {
$vars = self::getEnvironmentVariables(
// @phpstan-ignore-next-line
$this->laravel->environmentPath(),
// @phpstan-ignore-next-line
$this->laravel->environmentFile()
);
$repository = Env::getRepository();
foreach ($vars as $name) {
$repository->clear($name);
}
}
}
/**
* @param string $path
* @param string $file
* @return array
*/
protected static function getEnvironmentVariables($path, $file)
{
try {
$content = StoreBuilder::createWithNoNames()
->addPath($path)
->addName($file)
->make()
->read();
} catch (InvalidPathException $e) {
return [];
}
$vars = [];
foreach ((new Parser())->parse($content) as $entry) {
$vars[] = $entry->getName();
}
return $vars;
}
/**
* Check if the parallel dependencies are installed.
*
* @return bool
*/
protected function isParallelDependenciesInstalled()
{
return class_exists(\ParaTest\Console\Commands\ParaTestCommand::class);
}
/**
* Install parallel testing needed dependencies.
*
* @return void
*/
protected function installParallelDependencies()
{
$command = $this->findComposer().' require brianium/paratest --dev';
$process = Process::fromShellCommandline($command, null, null, null, null);
if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) {
try {
$process->setTty(true);
} catch (RuntimeException $e) {
$this->output->writeln('Warning: '.$e->getMessage());
}
}
try {
$process->run(function ($type, $line) {
$this->output->write($line);
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
}
}
/**
* Get the composer command for the environment.
*
* @return string
*/
protected function findComposer()
{
$composerPath = getcwd().'/composer.phar';
if (file_exists($composerPath)) {
return '"'.PHP_BINARY.'" '.$composerPath;
}
return 'composer';
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use NunoMaduro\Collision\Contracts\Provider as ProviderContract;
use Symfony\Component\Console\Exception\ExceptionInterface as SymfonyConsoleExceptionInterface;
use Throwable;
/**
* @internal
*/
final class ExceptionHandler implements ExceptionHandlerContract
{
/**
* Holds an instance of the application exception handler.
*
* @var \Illuminate\Contracts\Debug\ExceptionHandler
*/
protected $appExceptionHandler;
/**
* Holds an instance of the container.
*
* @var \Illuminate\Contracts\Container\Container
*/
protected $container;
/**
* Creates a new instance of the ExceptionHandler.
*/
public function __construct(Container $container, ExceptionHandlerContract $appExceptionHandler)
{
$this->container = $container;
$this->appExceptionHandler = $appExceptionHandler;
}
/**
* {@inheritdoc}
*/
public function report(Throwable $e)
{
$this->appExceptionHandler->report($e);
}
/**
* {@inheritdoc}
*/
public function render($request, Throwable $e)
{
return $this->appExceptionHandler->render($request, $e);
}
/**
* {@inheritdoc}
*/
public function renderForConsole($output, Throwable $e)
{
if ($e instanceof SymfonyConsoleExceptionInterface) {
$this->appExceptionHandler->renderForConsole($output, $e);
} else {
/** @var \NunoMaduro\Collision\Contracts\Provider $provider */
$provider = $this->container->make(ProviderContract::class);
$handler = $provider->register()
->getHandler()
->setOutput($output);
$handler->setInspector((new Inspector($e)));
$handler->handle();
}
}
/**
* Determine if the exception should be reported.
*
* @return bool
*/
public function shouldReport(Throwable $e)
{
return $this->appExceptionHandler->shouldReport($e);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use RuntimeException;
/**
* @internal
*/
final class RequirementsException extends RuntimeException implements RenderlessEditor, RenderlessTrace
{
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
use Throwable;
/**
* @internal
*/
final class IgnitionSolutionsRepository implements SolutionsRepository
{
/**
* Holds an instance of ignition solutions provider repository.
*
* @var \Spatie\Ignition\Contracts\SolutionProviderRepository
*/
protected $solutionProviderRepository;
/**
* IgnitionSolutionsRepository constructor.
*/
public function __construct(SolutionProviderRepository $solutionProviderRepository)
{
$this->solutionProviderRepository = $solutionProviderRepository;
}
/**
* {@inheritdoc}
*/
public function getFromThrowable(Throwable $throwable): array
{
return $this->solutionProviderRepository->getSolutionsForThrowable($throwable);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/**
* This file is part of Collision.
*
* (c) Nuno Maduro <enunomaduro@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace NunoMaduro\Collision\Adapters\Laravel;
use Whoops\Exception\Inspector as BaseInspector;
/**
* @internal
*/
final class Inspector extends BaseInspector
{
/**
* {@inheritdoc}
*/
protected function getTrace($e)
{
return $e->getTrace();
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* This file is part of Collision.
*
* (c) Nuno Maduro <enunomaduro@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace NunoMaduro\Collision\Adapters\Phpunit;
use ReflectionObject;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\Output;
/**
* @internal
*/
final class ConfigureIO
{
/**
* Configures both given input and output with
* options from the environment.
*
* @throws \ReflectionException
*/
public static function of(InputInterface $input, Output $output): void
{
$application = new Application();
$reflector = new ReflectionObject($application);
$method = $reflector->getMethod('configureIO');
$method->setAccessible(true);
$method->invoke($application, $input, $output);
}
}

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Test;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Framework\Warning;
use ReflectionObject;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Throwable;
/**
* @internal
*/
final class Printer implements \PHPUnit\TextUI\ResultPrinter
{
/**
* Holds an instance of the style.
*
* Style is a class we use to interact with output.
*
* @var Style
*/
private $style;
/**
* Holds the duration time of the test suite.
*
* @var Timer
*/
private $timer;
/**
* Holds the state of the test
* suite. The number of tests, etc.
*
* @var State
*/
private $state;
/**
* If the test suite has failed.
*
* @var bool
*/
private $failed = false;
/**
* Creates a new instance of the listener.
*
* @param ConsoleOutput $output
*
* @throws \ReflectionException
*/
public function __construct(\Symfony\Component\Console\Output\ConsoleOutputInterface $output = null, bool $verbose = false, string $colors = 'always')
{
$this->timer = Timer::start();
$decorated = $colors === 'always' || $colors === 'auto';
$output = $output ?? new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $decorated);
ConfigureIO::of(new ArgvInput(), $output);
$this->style = new Style($output);
$dummyTest = new class() extends TestCase
{
};
$this->state = State::from($dummyTest);
}
/**
* {@inheritdoc}
*/
public function addError(Test $testCase, Throwable $throwable, float $time): void
{
$this->failed = true;
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::FAIL, $throwable));
}
/**
* {@inheritdoc}
*/
public function addWarning(Test $testCase, Warning $warning, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::WARN, $warning));
}
/**
* {@inheritdoc}
*/
public function addFailure(Test $testCase, AssertionFailedError $error, float $time): void
{
$this->failed = true;
$testCase = $this->testCaseFromTest($testCase);
$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->state->add(TestResult::fromTestCase($testCase, TestResult::FAIL, $error));
}
/**
* {@inheritdoc}
*/
public function addIncompleteTest(Test $testCase, Throwable $throwable, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::INCOMPLETE, $throwable));
}
/**
* {@inheritdoc}
*/
public function addRiskyTest(Test $testCase, Throwable $throwable, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::RISKY, $throwable));
}
/**
* {@inheritdoc}
*/
public function addSkippedTest(Test $testCase, Throwable $throwable, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
$this->state->add(TestResult::fromTestCase($testCase, TestResult::SKIPPED, $throwable));
}
/**
* {@inheritdoc}
*/
public function startTestSuite(TestSuite $suite): void
{
if ($this->state->suiteTotalTests === null) {
$this->state->suiteTotalTests = $suite->count();
}
}
/**
* {@inheritdoc}
*/
public function endTestSuite(TestSuite $suite): void
{
// ..
}
/**
* {@inheritdoc}
*/
public function startTest(Test $testCase): void
{
$testCase = $this->testCaseFromTest($testCase);
// Let's check first if the testCase is over.
if ($this->state->testCaseHasChanged($testCase)) {
$this->style->writeCurrentTestCaseSummary($this->state);
$this->state->moveTo($testCase);
}
}
/**
* {@inheritdoc}
*/
public function endTest(Test $testCase, float $time): void
{
$testCase = $this->testCaseFromTest($testCase);
if (! $this->state->existsInTestCase($testCase)) {
$this->state->add(TestResult::fromTestCase($testCase, TestResult::PASS));
}
if ($testCase instanceof TestCase
&& $testCase->getTestResultObject() instanceof \PHPUnit\Framework\TestResult
&& ! $testCase->getTestResultObject()->isStrictAboutOutputDuringTests()
&& ! $testCase->hasExpectationOnOutput()) {
$this->style->write($testCase->getActualOutput());
}
}
/**
* Intentionally left blank as we output things on events of the listener.
*/
public function write(string $content): void
{
// ..
}
/**
* 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 $test): TestCase
{
if (! $test instanceof TestCase) {
throw new ShouldNotHappen();
}
return $test;
}
/**
* Intentionally left blank as we output things on events of the listener.
*/
public function printResult(\PHPUnit\Framework\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);
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class State
{
/**
* The complete test suite number of tests.
*
* @var int|null
*/
public $suiteTotalTests;
/**
* The complete test suite tests.
*
* @var array<int, TestResult>
*/
public $suiteTests = [];
/**
* The current test case class.
*
* @var string
*/
public $testCaseName;
/**
* The current test case tests.
*
* @var array<int, TestResult>
*/
public $testCaseTests = [];
/**
* The current test case tests.
*
* @var array<int, TestResult>
*/
public $toBePrintedCaseTests = [];
/**
* Header printed.
*
* @var bool
*/
public $headerPrinted = false;
/**
* The state constructor.
*/
private function __construct(string $testCaseName)
{
$this->testCaseName = $testCaseName;
}
/**
* Creates a new State starting from the given test case.
*/
public static function from(TestCase $test): self
{
return new self(self::getPrintableTestCaseName($test));
}
/**
* Adds the given test to the State.
*/
public function add(TestResult $test): void
{
$this->testCaseTests[] = $test;
$this->toBePrintedCaseTests[] = $test;
$this->suiteTests[] = $test;
}
/**
* Gets the test case title.
*/
public function getTestCaseTitle(): string
{
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::FAIL) {
return 'FAIL';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type !== TestResult::PASS) {
return 'WARN';
}
}
return 'PASS';
}
/**
* Gets the test case title color.
*/
public function getTestCaseTitleColor(): string
{
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::FAIL) {
return 'red';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type !== TestResult::PASS) {
return 'yellow';
}
}
return 'green';
}
/**
* Returns the number of tests on the current test case.
*/
public function testCaseTestsCount(): int
{
return count($this->testCaseTests);
}
/**
* Returns the number of tests on the complete test suite.
*/
public function testSuiteTestsCount(): int
{
return count($this->suiteTests);
}
/**
* Checks if the given test case is different from the current one.
*/
public function testCaseHasChanged(TestCase $testCase): bool
{
return self::getPrintableTestCaseName($testCase) !== $this->testCaseName;
}
/**
* Moves the a new test case.
*/
public function moveTo(TestCase $testCase): void
{
$this->testCaseName = self::getPrintableTestCaseName($testCase);
$this->testCaseTests = [];
$this->headerPrinted = false;
}
/**
* Foreach test in the test case.
*/
public function eachTestCaseTests(callable $callback): void
{
foreach ($this->toBePrintedCaseTests as $test) {
$callback($test);
}
$this->toBePrintedCaseTests = [];
}
public function countTestsInTestSuiteBy(string $type): int
{
return count(array_filter($this->suiteTests, function (TestResult $testResult) use ($type) {
return $testResult->type === $type;
}));
}
/**
* Checks if the given test already contains a result.
*/
public function existsInTestCase(TestCase $test): bool
{
foreach ($this->testCaseTests as $testResult) {
if (TestResult::makeDescription($test) === $testResult->description) {
return true;
}
}
return false;
}
/**
* Returns the printable test case name from the given `TestCase`.
*/
public static function getPrintableTestCaseName(TestCase $test): string
{
return $test instanceof HasPrintableTestCaseName
? $test->getPrintableTestCaseName()
: get_class($test);
}
}

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use NunoMaduro\Collision\Writer;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExceptionWrapper;
use PHPUnit\Framework\ExpectationFailedException;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Throwable;
use Whoops\Exception\Inspector;
/**
* @internal
*/
final class Style
{
/**
* @var ConsoleOutput
*/
private $output;
/**
* Style constructor.
*/
public function __construct(ConsoleOutputInterface $output)
{
if (! $output instanceof ConsoleOutput) {
throw new ShouldNotHappen();
}
$this->output = $output;
}
/**
* Prints the content.
*/
public function write(string $content): void
{
$this->output->write($content);
}
/**
* Prints the content similar too:.
*
* ```
* PASS Unit\ExampleTest
* ✓ basic test
* ```
*/
public function writeCurrentTestCaseSummary(State $state): void
{
if ($state->testCaseTestsCount() === 0) {
return;
}
if (! $state->headerPrinted) {
$this->output->writeln($this->titleLineFrom(
$state->getTestCaseTitle() === 'FAIL' ? 'white' : 'black',
$state->getTestCaseTitleColor(),
$state->getTestCaseTitle(),
$state->testCaseName
));
$state->headerPrinted = true;
}
$state->eachTestCaseTests(function (TestResult $testResult) {
$this->output->writeln($this->testLineFrom(
$testResult->color,
$testResult->icon,
$testResult->description,
$testResult->warning
));
});
}
/**
* Prints the content similar too:.
*
* ```
* PASS Unit\ExampleTest
* ✓ basic test
* ```
*/
public function writeErrorsSummary(State $state, bool $onFailure): void
{
$errors = array_filter($state->suiteTests, function (TestResult $testResult) {
return $testResult->type === TestResult::FAIL;
});
if (! $onFailure) {
$this->output->writeln(['', " \e[2m---\e[22m", '']);
}
array_map(function (TestResult $testResult) use ($onFailure) {
if (! $onFailure) {
$this->output->write(sprintf(
' <fg=red;options=bold>• %s </>> <fg=red;options=bold>%s</>',
$testResult->testCaseName,
$testResult->description
));
}
if (! $testResult->throwable instanceof Throwable) {
throw new ShouldNotHappen();
}
$this->writeError($testResult->throwable);
}, $errors);
}
/**
* Writes the final recap.
*/
public function writeRecap(State $state, Timer $timer = null): void
{
$types = [TestResult::FAIL, TestResult::WARN, TestResult::RISKY, TestResult::INCOMPLETE, TestResult::SKIPPED, TestResult::PASS];
foreach ($types as $type) {
if (($countTests = $state->countTestsInTestSuiteBy($type)) !== 0) {
$color = TestResult::makeColor($type);
$tests[] = "<fg=$color;options=bold>$countTests $type</>";
}
}
$pending = $state->suiteTotalTests - $state->testSuiteTestsCount();
if ($pending !== 0) {
$tests[] = "\e[2m$pending pending\e[22m";
}
if (! empty($tests)) {
$this->output->write([
"\n",
sprintf(
' <fg=white;options=bold>Tests: </><fg=default>%s</>',
implode(', ', $tests)
),
]);
}
if ($timer !== null) {
$timeElapsed = number_format($timer->result(), 2, '.', '');
$this->output->writeln([
'',
sprintf(
' <fg=white;options=bold>Time: </><fg=default>%ss</>',
$timeElapsed
),
]
);
}
$this->output->writeln('');
}
/**
* Displays a warning message.
*/
public function writeWarning(string $message): void
{
$this->output->writeln($this->testLineFrom('yellow', $message, ''));
}
/**
* Displays the error using Collision's writer
* and terminates with exit code === 1.
*/
public function writeError(Throwable $throwable): void
{
$writer = (new Writer())->setOutput($this->output);
if ($throwable instanceof AssertionFailedError) {
$writer->showTitle(false);
$this->output->write('', true);
}
$writer->ignoreFilesIn([
'/vendor\/bin\/pest/',
'/bin\/pest/',
'/vendor\/pestphp\/pest/',
'/vendor\/phpspec\/prophecy-phpunit/',
'/vendor\/phpspec\/prophecy/',
'/vendor\/phpunit\/phpunit\/src/',
'/vendor\/mockery\/mockery/',
'/vendor\/laravel\/dusk/',
'/vendor\/laravel\/framework\/src\/Illuminate\/Testing/',
'/vendor\/laravel\/framework\/src\/Illuminate\/Foundation\/Testing/',
'/vendor\/symfony\/framework-bundle\/Test/',
'/vendor\/symfony\/phpunit-bridge/',
'/vendor\/symfony\/dom-crawler/',
'/vendor\/symfony\/browser-kit/',
'/vendor\/symfony\/css-selector/',
'/vendor\/bin\/.phpunit/',
'/bin\/.phpunit/',
'/vendor\/bin\/simple-phpunit/',
'/bin\/phpunit/',
'/vendor\/coduo\/php-matcher\/src\/PHPUnit/',
'/vendor\/sulu\/sulu\/src\/Sulu\/Bundle\/TestBundle\/Testing/',
'/vendor\/webmozart\/assert/',
]);
if ($throwable instanceof ExceptionWrapper && $throwable->getOriginalException() !== null) {
$throwable = $throwable->getOriginalException();
}
$inspector = new Inspector($throwable);
$writer->write($inspector);
if ($throwable instanceof ExpectationFailedException && $comparisionFailure = $throwable->getComparisonFailure()) {
$diff = $comparisionFailure->getDiff();
$lines = explode(PHP_EOL, $diff);
$diff = '';
foreach ($lines as $line) {
if (0 === strpos($line, '-')) {
$line = '<fg=red>'.$line.'</>';
} elseif (0 === strpos($line, '+')) {
$line = '<fg=green>'.$line.'</>';
}
$diff .= $line.PHP_EOL;
}
$diff = trim((string) preg_replace("/\r|\n/", "\n ", $diff));
$this->output->write(" $diff");
}
$this->output->writeln('');
}
/**
* Returns the title contents.
*/
private function titleLineFrom(string $fg, string $bg, string $title, string $testCaseName): string
{
return sprintf(
"\n <fg=%s;bg=%s;options=bold> %s </><fg=default> %s</>",
$fg,
$bg,
$title,
$testCaseName
);
}
/**
* Returns the test contents.
*/
private function testLineFrom(string $fg, string $icon, string $description, string $warning = null): string
{
if (! empty($warning)) {
$warning = sprintf(
' → %s',
$warning
);
}
return sprintf(
" <fg=%s;options=bold>%s</><fg=default> \e[2m%s\e[22m</><fg=yellow>%s</>",
$fg,
$icon,
$description,
$warning
);
}
}

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName;
use PHPUnit\Framework\TestCase;
use Throwable;
/**
* @internal
*/
final class TestResult
{
public const FAIL = 'failed';
public const SKIPPED = 'skipped';
public const INCOMPLETE = 'incomplete';
public const RISKY = 'risky';
public const WARN = 'warnings';
public const RUNS = 'pending';
public const PASS = 'passed';
/**
* @readonly
*
* @var string
*/
public $testCaseName;
/**
* @readonly
*
* @var string
*/
public $description;
/**
* @readonly
*
* @var string
*/
public $type;
/**
* @readonly
*
* @var string
*/
public $icon;
/**
* @readonly
*
* @var string
*/
public $color;
/**
* @readonly
*
* @var Throwable|null
*/
public $throwable;
/**
* @readonly
*
* @var string
*/
public $warning = '';
/**
* Test constructor.
*/
private function __construct(string $testCaseName, string $description, string $type, string $icon, string $color, Throwable $throwable = null)
{
$this->testCaseName = $testCaseName;
$this->description = $description;
$this->type = $type;
$this->icon = $icon;
$this->color = $color;
$this->throwable = $throwable;
$asWarning = $this->type === TestResult::WARN
|| $this->type === TestResult::RISKY
|| $this->type === TestResult::SKIPPED
|| $this->type === TestResult::INCOMPLETE;
if ($throwable instanceof Throwable && $asWarning) {
$this->warning = trim((string) preg_replace("/\r|\n/", ' ', $throwable->getMessage()));
}
}
/**
* Creates a new test from the given test case.
*/
public static function fromTestCase(TestCase $testCase, string $type, Throwable $throwable = null): self
{
$testCaseName = State::getPrintableTestCaseName($testCase);
$description = self::makeDescription($testCase);
$icon = self::makeIcon($type);
$color = self::makeColor($type);
return new self($testCaseName, $description, $type, $icon, $color, $throwable);
}
/**
* Get the test case description.
*/
public static function makeDescription(TestCase $testCase): string
{
$name = $testCase->getName(false);
if ($testCase instanceof HasPrintableTestCaseName) {
return $name;
}
// First, lets replace underscore by spaces.
$name = str_replace('_', ' ', $name);
// Then, replace upper cases by spaces.
$name = (string) preg_replace('/([A-Z])/', ' $1', $name);
// Finally, if it starts with `test`, we remove it.
$name = (string) preg_replace('/^test/', '', $name);
// Removes spaces
$name = trim($name);
// Lower case everything
$name = mb_strtolower($name);
// Add the dataset name if it has one
if ($dataName = $testCase->dataName()) {
if (is_int($dataName)) {
$name .= sprintf(' with data set #%d', $dataName);
} else {
$name .= sprintf(' with data set "%s"', $dataName);
}
}
return $name;
}
/**
* Get the test case icon.
*/
public static function makeIcon(string $type): string
{
switch ($type) {
case self::FAIL:
return '';
case self::SKIPPED:
return '-';
case self::RISKY:
return '!';
case self::INCOMPLETE:
return '…';
case self::WARN:
return '!';
case self::RUNS:
return '•';
default:
return '✓';
}
}
/**
* Get the test case color.
*/
public static function makeColor(string $type): string
{
switch ($type) {
case self::FAIL:
return 'red';
case self::SKIPPED:
case self::INCOMPLETE:
case self::RISKY:
case self::WARN:
case self::RUNS:
return 'yellow';
default:
return 'green';
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
/**
* @internal
*/
final class Timer
{
/**
* @var float
*/
private $start;
/**
* Timer constructor.
*/
private function __construct(float $start)
{
$this->start = $start;
}
/**
* Starts the timer.
*/
public static function start(): Timer
{
return new self(microtime(true));
}
/**
* Returns the elapsed time in microseconds.
*/
public function result(): float
{
return microtime(true) - $this->start;
}
}