Initial commit

This commit is contained in:
2025-08-04 16:33:07 +03:30
commit f798e8e35c
9595 changed files with 1208683 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
<?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\Handler;
use NunoMaduro\Collision\Provider;
use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository;
use NunoMaduro\Collision\Writer;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
/**
* @internal
*
* @final
*/
class CollisionServiceProvider extends ServiceProvider
{
/**
* {@inheritdoc}
*/
protected bool $defer = true;
/**
* Boots application services.
*/
public function boot(): void
{
$this->commands([
TestCommand::class,
]);
}
/**
* {@inheritdoc}
*/
public function register(): void
{
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
$this->app->bind(Provider::class, function () {
if ($this->app->has(SolutionProviderRepository::class)) { // @phpstan-ignore-line
/** @var SolutionProviderRepository $solutionProviderRepository */
$solutionProviderRepository = $this->app->get(SolutionProviderRepository::class); // @phpstan-ignore-line
$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 [Provider::class];
}
}

View File

@@ -0,0 +1,385 @@
<?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 ParaTest\Options;
use RuntimeException;
use SebastianBergmann\Environment\Console;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputDefinition;
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}
{--compact : Indicates whether the compact printer should be used}
{--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}
{--profile : Lists top 10 slowest tests}
{--recreate-databases : Indicates if the test databases should be re-created}
{--drop-databases : Indicates if the test databases should be dropped}
{--without-databases : Indicates if database configuration should be performed}
';
/**
* 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()
{
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</>?"
: ' Did you install <href=https://xdebug.org/>Xdebug</> or <href=https://github.com/krakjoe/pcov>PCOV</>?'
));
$this->newLine();
return 1;
}
/** @var bool $usesParallel */
$usesParallel = $this->option('parallel');
if ($usesParallel && ! $this->isParallelDependenciesInstalled()) {
throw new RequirementsException('Running Collision 8.x artisan test command in parallel requires at least ParaTest (brianium/paratest) 7.x.');
}
$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)
));
}
}
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();
}
if ($this->option('ansi')) {
$arguments[] = '--colors=always';
} elseif ($this->option('no-ansi')) {
$arguments[] = '--colors=never';
} elseif ((new Console)->hasColorSupport()) {
$arguments[] = '--colors=always';
}
return $arguments;
}
/**
* Determines if Pest is being used.
*
* @return bool
*/
protected function usingPest()
{
return function_exists('\Pest\\version');
}
/**
* Get the array of arguments for running PHPUnit.
*
* @param array $options
* @return array
*/
protected function phpunitArguments($options)
{
$options = array_merge(['--no-output'], $options);
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '-q'
&& $option != '--quiet'
&& $option != '--coverage'
&& $option != '--compact'
&& $option != '--profile'
&& $option != '--ansi'
&& $option != '--no-ansi'
&& ! Str::startsWith($option, '--min');
}));
return array_merge($this->commonArguments(), ['--configuration='.$this->getConfigurationFile()], $options);
}
/**
* Get the configuration file.
*
* @return string
*/
protected function getConfigurationFile()
{
if (! file_exists($file = base_path('phpunit.xml'))) {
$file = base_path('phpunit.xml.dist');
}
return $file;
}
/**
* 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'
&& $option != '--ansi'
&& $option != '--no-ansi'
&& ! Str::startsWith($option, '--min')
&& ! Str::startsWith($option, '-p')
&& ! Str::startsWith($option, '--parallel')
&& ! Str::startsWith($option, '--recreate-databases')
&& ! Str::startsWith($option, '--drop-databases')
&& ! Str::startsWith($option, '--without-databases');
}));
$options = array_merge($this->commonArguments(), [
'--configuration='.$this->getConfigurationFile(),
"--runner=\Illuminate\Testing\ParallelRunner",
], $options);
$inputDefinition = new InputDefinition;
Options::setInputDefinition($inputDefinition);
$input = new ArgvInput($options, $inputDefinition);
/** @var non-empty-string $basePath */
$basePath = base_path();
$paraTestOptions = Options::fromConsoleInput(
$input,
$basePath,
);
if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) {
$cacheDirectory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__laravel_test_cache_directory';
$options[] = '--cache-directory';
$options[] = $cacheDirectory;
}
return $options;
}
/**
* Get the array of environment variables for running PHPUnit.
*
* @return array
*/
protected function phpunitEnvironmentVariables()
{
$variables = [
'COLLISION_PRINTER' => 'DefaultPrinter',
];
if ($this->option('compact')) {
$variables['COLLISION_PRINTER_COMPACT'] = 'true';
}
if ($this->option('profile')) {
$variables['COLLISION_PRINTER_PROFILE'] = 'true';
}
return $variables;
}
/**
* 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'),
'LARAVEL_PARALLEL_TESTING_WITHOUT_DATABASES' => $this->option('without-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(
$this->laravel->environmentPath(),
$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\ParaTestCommand::class);
}
}

View File

@@ -0,0 +1,121 @@
<?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\Provider;
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 Provider $provider */
$provider = $this->container->make(Provider::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);
}
/**
* Register a reportable callback.
*
* @return \Illuminate\Foundation\Exceptions\ReportableHandler
*/
public function reportable(callable $reportUsing)
{
return $this->appExceptionHandler->reportable($reportUsing);
}
/**
* Register a renderable callback.
*
* @return $this
*/
public function renderable(callable $renderUsing)
{
$this->appExceptionHandler->renderable($renderUsing);
return $this;
}
/**
* Do not report duplicate exceptions.
*
* @return $this
*/
public function dontReportDuplicates()
{
$this->appExceptionHandler->dontReportDuplicates();
return $this;
}
}

View File

@@ -0,0 +1,14 @@
<?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 NotSupportedYetException extends RuntimeException implements RenderlessEditor, RenderlessTrace {}

View File

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

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,12 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Adapters\Phpunit\Subscribers\EnsurePrinterIsRegisteredSubscriber;
use PHPUnit\Runner\Version;
if (class_exists(Version::class) && (int) Version::series() >= 10) {
EnsurePrinterIsRegisteredSubscriber::register();
}

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,434 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit\Printers;
use NunoMaduro\Collision\Adapters\Phpunit\ConfigureIO;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\Style;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use NunoMaduro\Collision\Exceptions\TestOutcome;
use Pest\Result;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\DeprecationTriggered;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\NoticeTriggered;
use PHPUnit\Event\Test\Passed;
use PHPUnit\Event\Test\PhpDeprecationTriggered;
use PHPUnit\Event\Test\PhpNoticeTriggered;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitWarningTriggered;
use PHPUnit\Event\Test\PhpWarningTriggered;
use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\WarningTriggered;
use PHPUnit\Event\TestRunner\DeprecationTriggered as TestRunnerDeprecationTriggered;
use PHPUnit\Event\TestRunner\ExecutionFinished;
use PHPUnit\Event\TestRunner\ExecutionStarted;
use PHPUnit\Event\TestRunner\WarningTriggered as TestRunnerWarningTriggered;
use PHPUnit\Framework\IncompleteTestError;
use PHPUnit\Framework\SkippedWithMessageException;
use PHPUnit\TestRunner\TestResult\Facade;
use PHPUnit\TextUI\Configuration\Registry;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
/**
* @internal
*/
final class DefaultPrinter
{
/**
* The output instance.
*/
private ConsoleOutput $output;
/**
* The state instance.
*/
private State $state;
/**
* The style instance.
*/
private Style $style;
/**
* If the printer should be compact.
*/
private static bool $compact = false;
/**
* If the printer should profile.
*/
private static bool $profile = false;
/**
* When profiling, holds a list of slow tests.
*/
private array $profileSlowTests = [];
/**
* The test started at in microseconds.
*/
private float $testStartedAt = 0.0;
/**
* If the printer should be verbose.
*/
private static bool $verbose = false;
/**
* Creates a new Printer instance.
*/
public function __construct(bool $colors)
{
$this->output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, $colors);
ConfigureIO::of(new ArgvInput, $this->output);
class_exists(\Pest\Collision\Events::class) && \Pest\Collision\Events::setOutput($this->output);
self::$verbose = $this->output->isVerbose();
$this->style = new Style($this->output);
$this->state = new State;
}
/**
* If the printer instances should be compact.
*/
public static function compact(?bool $value = null): bool
{
if (! is_null($value)) {
self::$compact = $value;
}
return ! self::$verbose && self::$compact;
}
/**
* If the printer instances should profile.
*/
public static function profile(?bool $value = null): bool
{
if (! is_null($value)) {
self::$profile = $value;
}
return self::$profile;
}
/**
* Defines if the output should be decorated or not.
*/
public function setDecorated(bool $decorated): void
{
$this->output->setDecorated($decorated);
}
/**
* Listen to the runner execution started event.
*/
public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $printedUnexpectedOutput): void
{
$this->output->write($printedUnexpectedOutput->output());
}
/**
* Listen to the runner execution started event.
*/
public function testRunnerExecutionStarted(ExecutionStarted $executionStarted): void
{
// ..
}
/**
* Listen to the test finished event.
*/
public function testFinished(Finished $event): void
{
$duration = (hrtime(true) - $this->testStartedAt) / 1_000_000;
$test = $event->test();
if (! $test instanceof TestMethod) {
throw new ShouldNotHappen;
}
if (! $this->state->existsInTestCase($event->test())) {
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::PASS));
}
$result = $this->state->setDuration($test, $duration);
if (self::$profile) {
$this->profileSlowTests[$event->test()->id()] = $result;
// Sort the slow tests by time, and keep only 10 of them.
uasort($this->profileSlowTests, static function (TestResult $a, TestResult $b) {
return $b->duration <=> $a->duration;
});
$this->profileSlowTests = array_slice($this->profileSlowTests, 0, 10);
}
}
/**
* Listen to the test prepared event.
*/
public function testPreparationStarted(PreparationStarted $event): void
{
$this->testStartedAt = hrtime(true);
$test = $event->test();
if (! $test instanceof TestMethod) {
throw new ShouldNotHappen;
}
if ($this->state->testCaseHasChanged($test)) {
$this->style->writeCurrentTestCaseSummary($this->state);
$this->state->moveTo($test);
}
}
/**
* Listen to the test errored event.
*/
public function testBeforeFirstTestMethodErrored(BeforeFirstTestMethodErrored $event): void
{
$this->state->add(TestResult::fromBeforeFirstTestMethodErrored($event));
}
/**
* Listen to the test errored event.
*/
public function testErrored(Errored $event): void
{
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $event->throwable()));
}
/**
* Listen to the test failed event.
*/
public function testFailed(Failed $event): void
{
$throwable = $event->throwable();
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $throwable));
}
/**
* Listen to the test marked incomplete event.
*/
public function testMarkedIncomplete(MarkedIncomplete $event): void
{
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::INCOMPLETE, $event->throwable()));
}
/**
* Listen to the test considered risky event.
*/
public function testConsideredRisky(ConsideredRisky $event): void
{
$throwable = ThrowableBuilder::from(new IncompleteTestError($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::RISKY, $throwable));
}
/**
* Listen to the test runner deprecation triggered.
*/
public function testRunnerDeprecationTriggered(TestRunnerDeprecationTriggered $event): void
{
$this->style->writeWarning($event->message());
}
/**
* Listen to the test runner warning triggered.
*/
public function testRunnerWarningTriggered(TestRunnerWarningTriggered $event): void
{
if (! str_starts_with($event->message(), 'No tests found in class')) {
$this->style->writeWarning($event->message());
}
}
/**
* Listen to the test runner warning triggered.
*/
public function testPhpDeprecationTriggered(PhpDeprecationTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable));
}
/**
* Listen to the test runner notice triggered.
*/
public function testPhpNoticeTriggered(PhpNoticeTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::NOTICE, $throwable));
}
/**
* Listen to the test php warning triggered event.
*/
public function testPhpWarningTriggered(PhpWarningTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable));
}
/**
* Listen to the test runner warning triggered.
*/
public function testPhpunitWarningTriggered(PhpunitWarningTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable));
}
/**
* Listen to the test deprecation triggered event.
*/
public function testDeprecationTriggered(DeprecationTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable));
}
/**
* Listen to the test phpunit deprecation triggered event.
*/
public function testPhpunitDeprecationTriggered(PhpunitDeprecationTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable));
}
/**
* Listen to the test phpunit error triggered event.
*/
public function testPhpunitErrorTriggered(PhpunitErrorTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $throwable));
}
/**
* Listen to the test warning triggered event.
*/
public function testNoticeTriggered(NoticeTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::NOTICE, $throwable));
}
/**
* Listen to the test warning triggered event.
*/
public function testWarningTriggered(WarningTriggered $event): void
{
$throwable = ThrowableBuilder::from(new TestOutcome($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable));
}
/**
* Listen to the test skipped event.
*/
public function testSkipped(Skipped $event): void
{
if ($event->message() === '__TODO__') {
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::TODO));
return;
}
$throwable = ThrowableBuilder::from(new SkippedWithMessageException($event->message()));
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::SKIPPED, $throwable));
}
/**
* Listen to the test finished event.
*/
public function testPassed(Passed $event): void
{
if (! $this->state->existsInTestCase($event->test())) {
$this->state->add(TestResult::fromTestCase($event->test(), TestResult::PASS));
}
}
/**
* Listen to the runner execution finished event.
*/
public function testRunnerExecutionFinished(ExecutionFinished $event): void
{
$result = Facade::result();
if (ResultReflection::numberOfTests(Facade::result()) === 0) {
$this->output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
'',
]);
return;
}
$this->style->writeCurrentTestCaseSummary($this->state);
if (self::$compact) {
$this->output->writeln(['']);
}
if (class_exists(Result::class)) {
$failed = Result::failed(Registry::get(), Facade::result());
} else {
$failed = ! Facade::result()->wasSuccessful();
}
$this->style->writeErrorsSummary($this->state);
$this->style->writeRecap($this->state, $event->telemetryInfo(), $result);
if (! $failed && count($this->profileSlowTests) > 0) {
$this->style->writeSlowTests($this->profileSlowTests, $event->telemetryInfo());
}
}
/**
* Reports the given throwable.
*/
public function report(Throwable $throwable): void
{
$this->style->writeError(ThrowableBuilder::from($throwable));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit\Printers;
use Throwable;
/**
* @internal
*
* @mixin DefaultPrinter
*/
final class ReportablePrinter
{
/**
* Creates a new Printer instance.
*/
public function __construct(private readonly DefaultPrinter $printer)
{
// ..
}
/**
* Calls the original method, but reports any errors to the reporter.
*/
public function __call(string $name, array $arguments): mixed
{
try {
return $this->printer->$name(...$arguments);
} catch (Throwable $throwable) {
$this->printer->report($throwable);
}
exit(1);
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
/**
* @internal
*/
final class State
{
/**
* The complete test suite tests.
*
* @var array<string, TestResult>
*/
public array $suiteTests = [];
/**
* The current test case class.
*/
public ?string $testCaseName;
/**
* The current test case tests.
*
* @var array<string, TestResult>
*/
public array $testCaseTests = [];
/**
* The current test case tests.
*
* @var array<string, TestResult>
*/
public array $toBePrintedCaseTests = [];
/**
* Header printed.
*/
public bool $headerPrinted = false;
/**
* The state constructor.
*/
public function __construct()
{
$this->testCaseName = '';
}
/**
* Checks if the given test already contains a result.
*/
public function existsInTestCase(Test $test): bool
{
return isset($this->testCaseTests[$test->id()]);
}
/**
* Adds the given test to the State.
*/
public function add(TestResult $test): void
{
$this->testCaseName = $test->testCaseName;
$levels = array_flip([
TestResult::PASS,
TestResult::RUNS,
TestResult::TODO,
TestResult::SKIPPED,
TestResult::WARN,
TestResult::NOTICE,
TestResult::DEPRECATED,
TestResult::RISKY,
TestResult::INCOMPLETE,
TestResult::FAIL,
]);
if (isset($this->testCaseTests[$test->id])) {
$existing = $this->testCaseTests[$test->id];
if ($levels[$existing->type] >= $levels[$test->type]) {
return;
}
}
$this->testCaseTests[$test->id] = $test;
$this->toBePrintedCaseTests[$test->id] = $test;
$this->suiteTests[$test->id] = $test;
}
/**
* Sets the duration of the given test, and returns the test result.
*/
public function setDuration(Test $test, float $duration): TestResult
{
$result = $this->testCaseTests[$test->id()];
$result->setDuration($duration);
return $result;
}
/**
* 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 && $test->type !== TestResult::TODO && $test->type !== TestResult::DEPRECATED && $test->type !== TestResult::NOTICE) {
return 'WARN';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::NOTICE) {
return 'NOTI';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::DEPRECATED) {
return 'DEPR';
}
}
if ($this->todosCount() > 0 && (count($this->testCaseTests) === $this->todosCount())) {
return 'TODO';
}
return 'PASS';
}
/**
* Gets the number of tests that are todos.
*/
public function todosCount(): int
{
return count(array_values(array_filter($this->testCaseTests, function (TestResult $test): bool {
return $test->type === TestResult::TODO;
})));
}
/**
* Gets the test case title color.
*/
public function getTestCaseFontColor(): string
{
if ($this->getTestCaseTitleColor() === 'blue') {
return 'white';
}
return $this->getTestCaseTitle() === 'FAIL' ? 'default' : 'black';
}
/**
* 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 && $test->type !== TestResult::TODO && $test->type !== TestResult::DEPRECATED) {
return 'yellow';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::DEPRECATED) {
return 'yellow';
}
}
foreach ($this->testCaseTests as $test) {
if ($test->type === TestResult::TODO) {
return 'blue';
}
}
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(TestMethod $test): bool
{
return self::getPrintableTestCaseName($test) !== $this->testCaseName;
}
/**
* Moves the an new test case.
*/
public function moveTo(TestMethod $test): void
{
$this->testCaseName = self::getPrintableTestCaseName($test);
$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;
}));
}
/**
* Returns the printable test case name from the given `TestCase`.
*/
public static function getPrintableTestCaseName(TestMethod $test): string
{
$className = explode('::', $test->id())[0];
if (is_subclass_of($className, HasPrintableTestCaseName::class)) {
return $className::getPrintableTestCaseName();
}
return $className;
}
}

View File

@@ -0,0 +1,564 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use Closure;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use NunoMaduro\Collision\Exceptions\TestException;
use NunoMaduro\Collision\Exceptions\TestOutcome;
use NunoMaduro\Collision\Writer;
use Pest\Expectation;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Telemetry\Info;
use PHPUnit\Framework\ExpectationFailedException;
use PHPUnit\Framework\IncompleteTestError;
use PHPUnit\Framework\SkippedWithMessageException;
use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult;
use PHPUnit\TextUI\Configuration\Registry;
use ReflectionClass;
use ReflectionFunction;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Termwind\Terminal;
use Whoops\Exception\Frame;
use Whoops\Exception\Inspector;
use function Termwind\render;
use function Termwind\renderUsing;
use function Termwind\terminal;
/**
* @internal
*/
final class Style
{
private int $compactProcessed = 0;
private int $compactSymbolsPerLine = 0;
private readonly Terminal $terminal;
private readonly ConsoleOutput $output;
/**
* @var string[]
*/
private const TYPES = [TestResult::DEPRECATED, TestResult::FAIL, TestResult::WARN, TestResult::RISKY, TestResult::INCOMPLETE, TestResult::NOTICE, TestResult::TODO, TestResult::SKIPPED, TestResult::PASS];
/**
* Style constructor.
*/
public function __construct(ConsoleOutputInterface $output)
{
if (! $output instanceof ConsoleOutput) {
throw new ShouldNotHappen;
}
$this->terminal = terminal();
$this->output = $output;
$this->compactSymbolsPerLine = $this->terminal->width() - 4;
}
/**
* Prints the content similar too:.
*
* ```
* WARN Your XML configuration validates against a deprecated schema...
* ```
*/
public function writeWarning(string $message): void
{
$this->output->writeln(['', ' <fg=black;bg=yellow;options=bold> WARN </> '.$message]);
}
/**
* Prints the content similar too:.
*
* ```
* WARN Your XML configuration validates against a deprecated schema...
* ```
*/
public function writeThrowable(\Throwable $throwable): void
{
$this->output->writeln(['', ' <fg=white;bg=red;options=bold> ERROR </> '.$throwable->getMessage()]);
}
/**
* Prints the content similar too:.
*
* ```
* PASS Unit\ExampleTest
* ✓ basic test
* ```
*/
public function writeCurrentTestCaseSummary(State $state): void
{
if ($state->testCaseTestsCount() === 0 || is_null($state->testCaseName)) {
return;
}
if (! $state->headerPrinted && ! DefaultPrinter::compact()) {
$this->output->writeln($this->titleLineFrom(
$state->getTestCaseFontColor(),
$state->getTestCaseTitleColor(),
$state->getTestCaseTitle(),
$state->testCaseName,
$state->todosCount(),
));
$state->headerPrinted = true;
}
$state->eachTestCaseTests(function (TestResult $testResult): void {
if ($testResult->description !== '') {
if (DefaultPrinter::compact()) {
$this->writeCompactDescriptionLine($testResult);
} else {
$this->writeDescriptionLine($testResult);
}
}
});
}
/**
* Prints the content similar too:.
*
* ```
* PASS Unit\ExampleTest
* ✓ basic test
* ```
*/
public function writeErrorsSummary(State $state): void
{
$configuration = Registry::get();
$failTypes = [
TestResult::FAIL,
];
if ($configuration->displayDetailsOnTestsThatTriggerNotices()) {
$failTypes[] = TestResult::NOTICE;
}
if ($configuration->displayDetailsOnTestsThatTriggerDeprecations()) {
$failTypes[] = TestResult::DEPRECATED;
}
if ($configuration->failOnWarning() || $configuration->displayDetailsOnTestsThatTriggerWarnings()) {
$failTypes[] = TestResult::WARN;
}
if ($configuration->failOnRisky()) {
$failTypes[] = TestResult::RISKY;
}
if ($configuration->failOnIncomplete() || $configuration->displayDetailsOnIncompleteTests()) {
$failTypes[] = TestResult::INCOMPLETE;
}
if ($configuration->failOnSkipped() || $configuration->displayDetailsOnSkippedTests()) {
$failTypes[] = TestResult::SKIPPED;
}
$failTypes = array_unique($failTypes);
$errors = array_values(array_filter($state->suiteTests, fn (TestResult $testResult) => in_array(
$testResult->type,
$failTypes,
true
)));
array_map(function (TestResult $testResult): void {
if (! $testResult->throwable instanceof Throwable) {
throw new ShouldNotHappen;
}
renderUsing($this->output);
render(<<<'HTML'
<div class="mx-2 text-red">
<hr/>
</div>
HTML
);
$testCaseName = $testResult->testCaseName;
$description = $testResult->description;
/** @var class-string $throwableClassName */
$throwableClassName = $testResult->throwable->className();
$throwableClassName = ! in_array($throwableClassName, [
ExpectationFailedException::class,
IncompleteTestError::class,
SkippedWithMessageException::class,
TestOutcome::class,
], true) ? sprintf('<span class="px-1 bg-red font-bold">%s</span>', (new ReflectionClass($throwableClassName))->getShortName())
: '';
$truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate';
renderUsing($this->output);
render(sprintf(<<<'HTML'
<div class="flex justify-between mx-2">
<span class="%s">
<span class="px-1 bg-%s %s font-bold uppercase">%s</span> <span class="font-bold">%s</span><span class="text-gray mx-1">></span><span>%s</span>
</span>
<span class="ml-1">
%s
</span>
</div>
HTML, $truncateClasses, $testResult->color === 'yellow' ? 'yellow-400' : $testResult->color, $testResult->color === 'yellow' ? 'text-black' : '', $testResult->type, $testCaseName, $description, $throwableClassName));
$this->writeError($testResult->throwable);
}, $errors);
}
/**
* Writes the final recap.
*/
public function writeRecap(State $state, Info $telemetry, PHPUnitTestResult $result): void
{
$tests = [];
foreach (self::TYPES as $type) {
if (($countTests = $state->countTestsInTestSuiteBy($type)) !== 0) {
$color = TestResult::makeColor($type);
if ($type === TestResult::WARN && $countTests < 2) {
$type = 'warning';
}
if ($type === TestResult::NOTICE && $countTests > 1) {
$type = 'notices';
}
if ($type === TestResult::TODO && $countTests > 1) {
$type = 'todos';
}
$tests[] = "<fg=$color;options=bold>$countTests $type</>";
}
}
$pending = ResultReflection::numberOfTests($result) - $result->numberOfTestsRun();
if ($pending > 0) {
$tests[] = "\e[2m$pending pending\e[22m";
}
$timeElapsed = number_format($telemetry->durationSinceStart()->asFloat(), 2, '.', '');
$this->output->writeln(['']);
if (! empty($tests)) {
$this->output->writeln([
sprintf(
' <fg=gray>Tests:</> <fg=default>%s</><fg=gray> (%s assertions)</>',
implode('<fg=gray>,</> ', $tests),
$result->numberOfAssertions(),
),
]);
}
$this->output->writeln([
sprintf(
' <fg=gray>Duration:</> <fg=default>%ss</>',
$timeElapsed
),
]);
$this->output->writeln('');
}
/**
* @param array<int, TestResult> $slowTests
*/
public function writeSlowTests(array $slowTests, Info $telemetry): void
{
$this->output->writeln(' <fg=gray>Top 10 slowest tests:</>');
$timeElapsed = $telemetry->durationSinceStart()->asFloat();
foreach ($slowTests as $testResult) {
$seconds = number_format($testResult->duration / 1000, 2, '.', '');
$color = ($testResult->duration / 1000) > $timeElapsed * 0.25 ? 'red' : ($testResult->duration > $timeElapsed * 0.1 ? 'yellow' : 'gray');
renderUsing($this->output);
render(sprintf(<<<'HTML'
<div class="flex justify-between space-x-1 mx-2">
<span class="flex-1">
<span class="font-bold">%s</span><span class="text-gray mx-1">></span><span class="text-gray">%s</span>
</span>
<span class="ml-1 font-bold text-%s">
%ss
</span>
</div>
HTML, $testResult->testCaseName, $testResult->description, $color, $seconds));
}
$timeElapsedInSlowTests = array_sum(array_map(fn (TestResult $testResult) => $testResult->duration / 1000, $slowTests));
$timeElapsedAsString = number_format($timeElapsed, 2, '.', '');
$percentageInSlowTestsAsString = number_format($timeElapsedInSlowTests * 100 / $timeElapsed, 2, '.', '');
$timeElapsedInSlowTestsAsString = number_format($timeElapsedInSlowTests, 2, '.', '');
renderUsing($this->output);
render(sprintf(<<<'HTML'
<div class="mx-2 mb-1 flex">
<div class="text-gray">
<hr/>
</div>
<div class="flex space-x-1 justify-between">
<span>
</span>
<span>
<span class="text-gray">(%s%% of %ss)</span>
<span class="ml-1 font-bold">%ss</span>
</span>
</div>
</div>
HTML, $percentageInSlowTestsAsString, $timeElapsedAsString, $timeElapsedInSlowTestsAsString));
}
/**
* 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);
$throwable = new TestException($throwable, $this->output->isVerbose());
$writer->showTitle(false);
$writer->ignoreFilesIn([
'/vendor\/nunomaduro\/collision/',
'/vendor\/bin\/pest/',
'/bin\/pest/',
'/vendor\/pestphp\/pest/',
'/vendor\/pestphp\/pest-plugin-arch/',
'/vendor\/phpspec\/prophecy-phpunit/',
'/vendor\/phpspec\/prophecy/',
'/vendor\/phpunit\/phpunit\/src/',
'/vendor\/mockery\/mockery/',
'/vendor\/laravel\/dusk/',
'/Illuminate\/Testing/',
'/Illuminate\/Foundation\/Testing/',
'/Illuminate\/Foundation\/Bootstrap\/HandleExceptions/',
'/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/',
$this->ignorePestPipes(...),
$this->ignorePestExtends(...),
$this->ignorePestInterceptors(...),
]);
/** @var \Throwable $throwable */
$inspector = new Inspector($throwable);
$writer->write($inspector);
}
/**
* Returns the title contents.
*/
private function titleLineFrom(string $fg, string $bg, string $title, string $testCaseName, int $todos): string
{
return sprintf(
"\n <fg=%s;bg=%s;options=bold> %s </><fg=default> %s</>%s",
$fg,
$bg,
$title,
$testCaseName,
$todos > 0 ? sprintf('<fg=gray> - %s todo%s</>', $todos, $todos > 1 ? 's' : '') : '',
);
}
/**
* Writes a description line.
*/
private function writeCompactDescriptionLine(TestResult $result): void
{
$symbolsOnCurrentLine = $this->compactProcessed % $this->compactSymbolsPerLine;
if ($symbolsOnCurrentLine >= $this->terminal->width() - 4) {
$symbolsOnCurrentLine = 0;
}
if ($symbolsOnCurrentLine === 0) {
$this->output->writeln('');
$this->output->write(' ');
}
$this->output->write(sprintf('<fg=%s;options=bold>%s</>', $result->compactColor, $result->compactIcon));
$this->compactProcessed++;
}
/**
* Writes a description line.
*/
private function writeDescriptionLine(TestResult $result): void
{
if (! empty($warning = $result->warning)) {
if (! str_contains($warning, "\n")) {
$warning = sprintf(
' → %s',
$warning
);
} else {
$warningLines = explode("\n", $warning);
$warning = '';
foreach ($warningLines as $w) {
$warning .= sprintf(
"\n <fg=yellow;options=bold>⇂ %s</>",
trim($w)
);
}
}
}
$seconds = '';
if (($result->duration / 1000) > 0.0) {
$seconds = number_format($result->duration / 1000, 2, '.', '');
$seconds = $seconds !== '0.00' ? sprintf('<span class="text-gray mr-2">%ss</span>', $seconds) : '';
}
if (isset($_SERVER['REBUILD_SNAPSHOTS']) || (isset($_SERVER['COLLISION_IGNORE_DURATION']) && $_SERVER['COLLISION_IGNORE_DURATION'] === 'true')) {
$seconds = '';
}
$truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate';
if ($warning !== '') {
$warning = sprintf('<span class="ml-1 text-yellow">%s</span>', $warning);
if (! empty($result->warningSource)) {
$warning .= ' // '.$result->warningSource;
}
}
$description = $result->description;
/** @var string $description */
$description = preg_replace('/`([^`]+)`/', '<span class="text-white">$1</span>', $description);
if (class_exists(\Pest\Collision\Events::class)) {
$description = \Pest\Collision\Events::beforeTestMethodDescription($result, $description);
}
renderUsing($this->output);
render(sprintf(<<<'HTML'
<div class="%s ml-2">
<span class="%s text-gray">
<span class="text-%s font-bold">%s</span><span class="ml-1 text-gray">%s</span>%s
</span>%s
</div>
HTML, $seconds === '' ? '' : 'flex space-x-1 justify-between', $truncateClasses, $result->color, $result->icon, $description, $warning, $seconds));
class_exists(\Pest\Collision\Events::class) && \Pest\Collision\Events::afterTestMethodDescription($result);
}
/**
* @param Frame $frame
*/
private function ignorePestPipes($frame): bool
{
if (class_exists(Expectation::class)) {
$reflection = new ReflectionClass(Expectation::class);
/** @var array<string, array<Closure(Closure, mixed ...$arguments): void>> $expectationPipes */
$expectationPipes = $reflection->getStaticPropertyValue('pipes', []);
foreach ($expectationPipes as $pipes) {
foreach ($pipes as $pipeClosure) {
if ($this->isFrameInClosure($frame, $pipeClosure)) {
return true;
}
}
}
}
return false;
}
/**
* @param Frame $frame
*/
private function ignorePestExtends($frame): bool
{
if (class_exists(Expectation::class)) {
$reflection = new ReflectionClass(Expectation::class);
/** @var array<string, Closure> $extends */
$extends = $reflection->getStaticPropertyValue('extends', []);
foreach ($extends as $extendClosure) {
if ($this->isFrameInClosure($frame, $extendClosure)) {
return true;
}
}
}
return false;
}
/**
* @param Frame $frame
*/
private function ignorePestInterceptors($frame): bool
{
if (class_exists(Expectation::class)) {
$reflection = new ReflectionClass(Expectation::class);
/** @var array<string, array<Closure(Closure, mixed ...$arguments): void>> $expectationInterceptors */
$expectationInterceptors = $reflection->getStaticPropertyValue('interceptors', []);
foreach ($expectationInterceptors as $pipes) {
foreach ($pipes as $pipeClosure) {
if ($this->isFrameInClosure($frame, $pipeClosure)) {
return true;
}
}
}
}
return false;
}
/**
* @param Frame $frame
*/
private function isFrameInClosure($frame, Closure $closure): bool
{
$reflection = new ReflectionFunction($closure);
$sanitizedPath = (string) str_replace('\\', '/', (string) $frame->getFile());
/** @phpstan-ignore-next-line */
$sanitizedClosurePath = (string) str_replace('\\', '/', $reflection->getFileName());
if ($sanitizedPath === $sanitizedClosurePath) {
if ($reflection->getStartLine() <= $frame->getLine() && $frame->getLine() <= $reflection->getEndLine()) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit\Subscribers;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\ReportablePrinter;
use PHPUnit\Event\Application\Started;
use PHPUnit\Event\Application\StartedSubscriber;
use PHPUnit\Event\Facade;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodErroredSubscriber;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
use PHPUnit\Event\Test\DeprecationTriggered;
use PHPUnit\Event\Test\DeprecationTriggeredSubscriber;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
use PHPUnit\Event\Test\NoticeTriggered;
use PHPUnit\Event\Test\NoticeTriggeredSubscriber;
use PHPUnit\Event\Test\Passed;
use PHPUnit\Event\Test\PassedSubscriber;
use PHPUnit\Event\Test\PhpDeprecationTriggered;
use PHPUnit\Event\Test\PhpDeprecationTriggeredSubscriber;
use PHPUnit\Event\Test\PhpNoticeTriggered;
use PHPUnit\Event\Test\PhpNoticeTriggeredSubscriber;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitDeprecationTriggeredSubscriber;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggeredSubscriber;
use PHPUnit\Event\Test\PhpunitWarningTriggered;
use PHPUnit\Event\Test\PhpunitWarningTriggeredSubscriber;
use PHPUnit\Event\Test\PhpWarningTriggered;
use PHPUnit\Event\Test\PhpWarningTriggeredSubscriber;
use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PreparationStartedSubscriber;
use PHPUnit\Event\Test\PrintedUnexpectedOutput;
use PHPUnit\Event\Test\PrintedUnexpectedOutputSubscriber;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
use PHPUnit\Event\Test\WarningTriggered;
use PHPUnit\Event\Test\WarningTriggeredSubscriber;
use PHPUnit\Event\TestRunner\Configured;
use PHPUnit\Event\TestRunner\ConfiguredSubscriber;
use PHPUnit\Event\TestRunner\DeprecationTriggered as TestRunnerDeprecationTriggered;
use PHPUnit\Event\TestRunner\DeprecationTriggeredSubscriber as TestRunnerDeprecationTriggeredSubscriber;
use PHPUnit\Event\TestRunner\ExecutionFinished;
use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;
use PHPUnit\Event\TestRunner\ExecutionStarted;
use PHPUnit\Event\TestRunner\ExecutionStartedSubscriber;
use PHPUnit\Event\TestRunner\WarningTriggered as TestRunnerWarningTriggered;
use PHPUnit\Event\TestRunner\WarningTriggeredSubscriber as TestRunnerWarningTriggeredSubscriber;
use PHPUnit\Runner\Version;
if (class_exists(Version::class) && (int) Version::series() >= 10) {
/**
* @internal
*/
final class EnsurePrinterIsRegisteredSubscriber implements StartedSubscriber
{
/**
* If this subscriber has been registered on PHPUnit's facade.
*/
private static bool $registered = false;
/**
* Runs the subscriber.
*/
public function notify(Started $event): void
{
$printer = new ReportablePrinter(new DefaultPrinter(true));
if (isset($_SERVER['COLLISION_PRINTER_COMPACT'])) {
DefaultPrinter::compact(true);
}
if (isset($_SERVER['COLLISION_PRINTER_PROFILE'])) {
DefaultPrinter::profile(true);
}
$subscribers = [
// Configured
new class($printer) extends Subscriber implements ConfiguredSubscriber
{
public function notify(Configured $event): void
{
$this->printer()->setDecorated(
$event->configuration()->colors()
);
}
},
// Test
new class($printer) extends Subscriber implements PrintedUnexpectedOutputSubscriber
{
public function notify(PrintedUnexpectedOutput $event): void
{
$this->printer()->testPrintedUnexpectedOutput($event);
}
},
// Test Runner
new class($printer) extends Subscriber implements ExecutionStartedSubscriber
{
public function notify(ExecutionStarted $event): void
{
$this->printer()->testRunnerExecutionStarted($event);
}
},
new class($printer) extends Subscriber implements ExecutionFinishedSubscriber
{
public function notify(ExecutionFinished $event): void
{
$this->printer()->testRunnerExecutionFinished($event);
}
},
// Test > Hook Methods
new class($printer) extends Subscriber implements BeforeFirstTestMethodErroredSubscriber
{
public function notify(BeforeFirstTestMethodErrored $event): void
{
$this->printer()->testBeforeFirstTestMethodErrored($event);
}
},
// Test > Lifecycle ...
new class($printer) extends Subscriber implements FinishedSubscriber
{
public function notify(Finished $event): void
{
$this->printer()->testFinished($event);
}
},
new class($printer) extends Subscriber implements PreparationStartedSubscriber
{
public function notify(PreparationStarted $event): void
{
$this->printer()->testPreparationStarted($event);
}
},
// Test > Issues ...
new class($printer) extends Subscriber implements ConsideredRiskySubscriber
{
public function notify(ConsideredRisky $event): void
{
$this->printer()->testConsideredRisky($event);
}
},
new class($printer) extends Subscriber implements DeprecationTriggeredSubscriber
{
public function notify(DeprecationTriggered $event): void
{
$this->printer()->testDeprecationTriggered($event);
}
},
new class($printer) extends Subscriber implements TestRunnerDeprecationTriggeredSubscriber
{
public function notify(TestRunnerDeprecationTriggered $event): void
{
$this->printer()->testRunnerDeprecationTriggered($event);
}
},
new class($printer) extends Subscriber implements TestRunnerWarningTriggeredSubscriber
{
public function notify(TestRunnerWarningTriggered $event): void
{
$this->printer()->testRunnerWarningTriggered($event);
}
},
new class($printer) extends Subscriber implements PhpDeprecationTriggeredSubscriber
{
public function notify(PhpDeprecationTriggered $event): void
{
$this->printer()->testPhpDeprecationTriggered($event);
}
},
new class($printer) extends Subscriber implements PhpunitDeprecationTriggeredSubscriber
{
public function notify(PhpunitDeprecationTriggered $event): void
{
$this->printer()->testPhpunitDeprecationTriggered($event);
}
},
new class($printer) extends Subscriber implements PhpNoticeTriggeredSubscriber
{
public function notify(PhpNoticeTriggered $event): void
{
$this->printer()->testPhpNoticeTriggered($event);
}
},
new class($printer) extends Subscriber implements PhpWarningTriggeredSubscriber
{
public function notify(PhpWarningTriggered $event): void
{
$this->printer()->testPhpWarningTriggered($event);
}
},
new class($printer) extends Subscriber implements PhpunitWarningTriggeredSubscriber
{
public function notify(PhpunitWarningTriggered $event): void
{
$this->printer()->testPhpunitWarningTriggered($event);
}
},
new class($printer) extends Subscriber implements PhpunitErrorTriggeredSubscriber
{
public function notify(PhpunitErrorTriggered $event): void
{
$this->printer()->testPhpunitErrorTriggered($event);
}
},
// Test > Outcome ...
new class($printer) extends Subscriber implements ErroredSubscriber
{
public function notify(Errored $event): void
{
$this->printer()->testErrored($event);
}
},
new class($printer) extends Subscriber implements FailedSubscriber
{
public function notify(Failed $event): void
{
$this->printer()->testFailed($event);
}
},
new class($printer) extends Subscriber implements MarkedIncompleteSubscriber
{
public function notify(MarkedIncomplete $event): void
{
$this->printer()->testMarkedIncomplete($event);
}
},
new class($printer) extends Subscriber implements NoticeTriggeredSubscriber
{
public function notify(NoticeTriggered $event): void
{
$this->printer()->testNoticeTriggered($event);
}
},
new class($printer) extends Subscriber implements PassedSubscriber
{
public function notify(Passed $event): void
{
$this->printer()->testPassed($event);
}
},
new class($printer) extends Subscriber implements SkippedSubscriber
{
public function notify(Skipped $event): void
{
$this->printer()->testSkipped($event);
}
},
new class($printer) extends Subscriber implements WarningTriggeredSubscriber
{
public function notify(WarningTriggered $event): void
{
$this->printer()->testWarningTriggered($event);
}
},
];
Facade::instance()->registerSubscribers(...$subscribers);
}
/**
* Registers the subscriber on PHPUnit's facade.
*/
public static function register(): void
{
$shouldRegister = self::$registered === false
&& isset($_SERVER['COLLISION_PRINTER']);
if ($shouldRegister) {
self::$registered = true;
Facade::instance()->registerSubscriber(new self);
}
}
}
}

View File

@@ -0,0 +1,43 @@
<?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\Subscribers;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\ReportablePrinter;
/**
* @internal
*/
abstract class Subscriber
{
/**
* The printer instance.
*/
private ReportablePrinter $printer;
/**
* Creates a new subscriber.
*/
public function __construct(ReportablePrinter $printer)
{
$this->printer = $printer;
}
/**
* Returns the printer instance.
*/
protected function printer(): ReportablePrinter
{
return $this->printer;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit\Support;
use PHPUnit\TestRunner\TestResult\TestResult;
/**
* @internal
*/
final class ResultReflection
{
/**
* The number of processed tests.
*/
public static function numberOfTests(TestResult $testResult): int
{
return (fn () => $this->numberOfTests)->call($testResult);
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Phpunit;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
/**
* @internal
*/
final class TestResult
{
public const FAIL = 'failed';
public const SKIPPED = 'skipped';
public const INCOMPLETE = 'incomplete';
public const TODO = 'todo';
public const RISKY = 'risky';
public const DEPRECATED = 'deprecated';
public const NOTICE = 'notice';
public const WARN = 'warnings';
public const RUNS = 'pending';
public const PASS = 'passed';
public string $id;
public string $testCaseName;
public string $description;
public string $type;
public string $compactIcon;
public string $icon;
public string $compactColor;
public string $color;
public float $duration;
public ?Throwable $throwable;
public string $warning = '';
public string $warningSource = '';
public array $context;
/**
* Creates a new TestResult instance.
*/
private function __construct(string $id, string $testCaseName, string $description, string $type, string $icon, string $compactIcon, string $color, string $compactColor, array $context, ?Throwable $throwable = null)
{
$this->id = $id;
$this->testCaseName = $testCaseName;
$this->description = $description;
$this->type = $type;
$this->icon = $icon;
$this->compactIcon = $compactIcon;
$this->color = $color;
$this->compactColor = $compactColor;
$this->throwable = $throwable;
$this->context = $context;
$this->duration = 0.0;
$asWarning = $this->type === TestResult::WARN
|| $this->type === TestResult::RISKY
|| $this->type === TestResult::SKIPPED
|| $this->type === TestResult::DEPRECATED
|| $this->type === TestResult::NOTICE
|| $this->type === TestResult::INCOMPLETE;
if ($throwable instanceof Throwable && $asWarning) {
if (in_array($this->type, [TestResult::DEPRECATED, TestResult::NOTICE])) {
foreach (explode("\n", $throwable->stackTrace()) as $line) {
if (strpos($line, 'vendor/nunomaduro/collision') === false) {
$this->warningSource = str_replace(getcwd().'/', '', $line);
break;
}
}
}
$this->warning .= trim((string) preg_replace("/\r|\n/", ' ', $throwable->message()));
// pest specific
$this->warning = str_replace('__pest_evaluable_', '', $this->warning);
$this->warning = str_replace('This test depends on "P\\', 'This test depends on "', $this->warning);
}
}
/**
* Sets the telemetry information.
*/
public function setDuration(float $duration): void
{
$this->duration = $duration;
}
/**
* Creates a new test from the given test case.
*/
public static function fromTestCase(Test $test, string $type, ?Throwable $throwable = null): self
{
if (! $test instanceof TestMethod) {
throw new ShouldNotHappen;
}
if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) {
$testCaseName = $test->className()::getPrintableTestCaseName();
$context = method_exists($test->className(), 'getPrintableContext') ? $test->className()::getPrintableContext() : [];
} else {
$testCaseName = $test->className();
$context = [];
}
$description = self::makeDescription($test);
$icon = self::makeIcon($type);
$compactIcon = self::makeCompactIcon($type);
$color = self::makeColor($type);
$compactColor = self::makeCompactColor($type);
return new self($test->id(), $testCaseName, $description, $type, $icon, $compactIcon, $color, $compactColor, $context, $throwable);
}
/**
* Creates a new test from the given Pest Parallel Test Case.
*/
public static function fromPestParallelTestCase(Test $test, string $type, ?Throwable $throwable = null): self
{
if (! $test instanceof TestMethod) {
throw new ShouldNotHappen;
}
if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) {
$testCaseName = $test->className()::getPrintableTestCaseName();
$description = $test->testDox()->prettifiedMethodName();
} else {
$testCaseName = $test->className();
$description = self::makeDescription($test);
}
$icon = self::makeIcon($type);
$compactIcon = self::makeCompactIcon($type);
$color = self::makeColor($type);
$compactColor = self::makeCompactColor($type);
return new self($test->id(), $testCaseName, $description, $type, $icon, $compactIcon, $color, $compactColor, [], $throwable);
}
/**
* Creates a new test from the given test case.
*/
public static function fromBeforeFirstTestMethodErrored(BeforeFirstTestMethodErrored $event): self
{
if (is_subclass_of($event->testClassName(), HasPrintableTestCaseName::class)) {
$testCaseName = $event->testClassName()::getPrintableTestCaseName();
} else {
$testCaseName = $event->testClassName();
}
$description = '';
$icon = self::makeIcon(self::FAIL);
$compactIcon = self::makeCompactIcon(self::FAIL);
$color = self::makeColor(self::FAIL);
$compactColor = self::makeCompactColor(self::FAIL);
return new self($testCaseName, $testCaseName, $description, self::FAIL, $icon, $compactIcon, $color, $compactColor, [], $event->throwable());
}
/**
* Get the test case description.
*/
public static function makeDescription(TestMethod $test): string
{
if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) {
return $test->className()::getLatestPrintableTestCaseMethodName();
}
$name = $test->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);
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::DEPRECATED:
case self::WARN:
case self::RISKY:
case self::NOTICE:
return '!';
case self::INCOMPLETE:
return '…';
case self::TODO:
return '↓';
case self::RUNS:
return '•';
default:
return '✓';
}
}
/**
* Get the test case compact icon.
*/
public static function makeCompactIcon(string $type): string
{
switch ($type) {
case self::FAIL:
return '';
case self::SKIPPED:
return 's';
case self::DEPRECATED:
case self::NOTICE:
case self::WARN:
case self::RISKY:
return '!';
case self::INCOMPLETE:
return 'i';
case self::TODO:
return 't';
case self::RUNS:
return '•';
default:
return '.';
}
}
/**
* Get the test case compact color.
*/
public static function makeCompactColor(string $type): string
{
switch ($type) {
case self::FAIL:
return 'red';
case self::DEPRECATED:
case self::NOTICE:
case self::SKIPPED:
case self::INCOMPLETE:
case self::RISKY:
case self::WARN:
case self::RUNS:
return 'yellow';
case self::TODO:
return 'cyan';
default:
return 'gray';
}
}
/**
* Get the test case color.
*/
public static function makeColor(string $type): string
{
switch ($type) {
case self::TODO:
return 'cyan';
case self::FAIL:
return 'red';
case self::DEPRECATED:
case self::NOTICE:
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;
/**
* @internal
*
* @see \Tests\Unit\ArgumentFormatterTest
*/
final class ArgumentFormatter
{
private const MAX_STRING_LENGTH = 1000;
public function format(array $arguments, bool $recursive = true): string
{
$result = [];
foreach ($arguments as $argument) {
switch (true) {
case is_string($argument):
$result[] = '"'.(mb_strlen($argument) > self::MAX_STRING_LENGTH ? mb_substr($argument, 0, self::MAX_STRING_LENGTH).'...' : $argument).'"';
break;
case is_array($argument):
$associative = array_keys($argument) !== range(0, count($argument) - 1);
if ($recursive && $associative && count($argument) <= 5) {
$result[] = '['.$this->format($argument, false).']';
}
break;
case is_object($argument):
$class = get_class($argument);
$result[] = "Object($class)";
break;
}
}
return implode(', ', $result);
}
}

View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use InvalidArgumentException;
use NunoMaduro\Collision\Exceptions\InvalidStyleException;
use NunoMaduro\Collision\Exceptions\ShouldNotHappen;
/**
* @internal
*
* @final
*/
class ConsoleColor
{
public const FOREGROUND = 38;
public const BACKGROUND = 48;
public const COLOR256_REGEXP = '~^(bg_)?color_(\d{1,3})$~';
public const RESET_STYLE = 0;
private bool $forceStyle = false;
/** @var array */
private const STYLES = [
'none' => null,
'bold' => '1',
'dark' => '2',
'italic' => '3',
'underline' => '4',
'blink' => '5',
'reverse' => '7',
'concealed' => '8',
'default' => '39',
'black' => '30',
'red' => '31',
'green' => '32',
'yellow' => '33',
'blue' => '34',
'magenta' => '35',
'cyan' => '36',
'light_gray' => '37',
'dark_gray' => '90',
'light_red' => '91',
'light_green' => '92',
'light_yellow' => '93',
'light_blue' => '94',
'light_magenta' => '95',
'light_cyan' => '96',
'white' => '97',
'bg_default' => '49',
'bg_black' => '40',
'bg_red' => '41',
'bg_green' => '42',
'bg_yellow' => '43',
'bg_blue' => '44',
'bg_magenta' => '45',
'bg_cyan' => '46',
'bg_light_gray' => '47',
'bg_dark_gray' => '100',
'bg_light_red' => '101',
'bg_light_green' => '102',
'bg_light_yellow' => '103',
'bg_light_blue' => '104',
'bg_light_magenta' => '105',
'bg_light_cyan' => '106',
'bg_white' => '107',
];
private array $themes = [];
/**
* @throws InvalidStyleException
* @throws InvalidArgumentException
*/
public function apply(array|string $style, string $text): string
{
if (! $this->isStyleForced() && ! $this->isSupported()) {
return $text;
}
if (is_string($style)) {
$style = [$style];
}
if (! is_array($style)) {
throw new InvalidArgumentException('Style must be string or array.');
}
$sequences = [];
foreach ($style as $s) {
if (isset($this->themes[$s])) {
$sequences = array_merge($sequences, $this->themeSequence($s));
} elseif ($this->isValidStyle($s)) {
$sequences[] = $this->styleSequence($s);
} else {
throw new ShouldNotHappen;
}
}
$sequences = array_filter($sequences, function ($val) {
return $val !== null;
});
if (empty($sequences)) {
return $text;
}
return $this->escSequence(implode(';', $sequences)).$text.$this->escSequence(self::RESET_STYLE);
}
public function setForceStyle(bool $forceStyle): void
{
$this->forceStyle = $forceStyle;
}
public function isStyleForced(): bool
{
return $this->forceStyle;
}
public function setThemes(array $themes): void
{
$this->themes = [];
foreach ($themes as $name => $styles) {
$this->addTheme($name, $styles);
}
}
public function addTheme(string $name, array|string $styles): void
{
if (is_string($styles)) {
$styles = [$styles];
}
if (! is_array($styles)) {
throw new InvalidArgumentException('Style must be string or array.');
}
foreach ($styles as $style) {
if (! $this->isValidStyle($style)) {
throw new InvalidStyleException($style);
}
}
$this->themes[$name] = $styles;
}
public function getThemes(): array
{
return $this->themes;
}
public function hasTheme(string $name): bool
{
return isset($this->themes[$name]);
}
public function removeTheme(string $name): void
{
unset($this->themes[$name]);
}
public function isSupported(): bool
{
// The COLLISION_FORCE_COLORS variable is for internal purposes only
if (getenv('COLLISION_FORCE_COLORS') !== false) {
return true;
}
if (DIRECTORY_SEPARATOR === '\\') {
return getenv('ANSICON') !== false || getenv('ConEmuANSI') === 'ON';
}
return function_exists('posix_isatty') && @posix_isatty(STDOUT);
}
public function are256ColorsSupported(): bool
{
if (DIRECTORY_SEPARATOR === '\\') {
return function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(STDOUT);
}
return strpos((string) getenv('TERM'), '256color') !== false;
}
public function getPossibleStyles(): array
{
return array_keys(self::STYLES);
}
private function themeSequence(string $name): array
{
$sequences = [];
foreach ($this->themes[$name] as $style) {
$sequences[] = $this->styleSequence($style);
}
return $sequences;
}
private function styleSequence(string $style): ?string
{
if (array_key_exists($style, self::STYLES)) {
return self::STYLES[$style];
}
if (! $this->are256ColorsSupported()) {
return null;
}
preg_match(self::COLOR256_REGEXP, $style, $matches);
$type = $matches[1] === 'bg_' ? self::BACKGROUND : self::FOREGROUND; // @phpstan-ignore-line
$value = $matches[2]; // @phpstan-ignore-line
return "$type;5;$value";
}
private function isValidStyle(string $style): bool
{
return array_key_exists($style, self::STYLES) || preg_match(self::COLOR256_REGEXP, $style);
}
private function escSequence(string|int $value): string
{
return "\033[{$value}m";
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts\Adapters\Phpunit;
/**
* @internal
*/
interface HasPrintableTestCaseName
{
/**
* The printable test case name.
*/
public static function getPrintableTestCaseName(): string;
/**
* The printable test case method name.
*/
public function getPrintableTestCaseMethodName(): string;
/**
* The "latest" printable test case method name.
*/
public static function getLatestPrintableTestCaseMethodName(): string;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace NunoMaduro\Collision\Contracts;
use Whoops\Exception\Frame;
interface RenderableOnCollisionEditor
{
/**
* Returns the frame to be used on the Collision Editor.
*/
public function toCollisionEditor(): Frame;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface RenderlessEditor {}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface RenderlessTrace {}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
use Spatie\Ignition\Contracts\Solution;
use Throwable;
/**
* @internal
*/
interface SolutionsRepository
{
/**
* Gets the solutions from the given `$throwable`.
*
* @return array<int, Solution>
*/
public function getFromThrowable(Throwable $throwable): array; // @phpstan-ignore-line
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
use function Termwind\render;
use function Termwind\renderUsing;
use function Termwind\terminal;
/**
* @internal
*/
final class Coverage
{
/**
* Returns the coverage path.
*/
public static function getPath(): string
{
return implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__),
'.temp',
'coverage',
]);
}
/**
* Runs true there is any code coverage driver available.
*/
public static function isAvailable(): bool
{
$runtime = new Runtime;
if (! $runtime->canCollectCodeCoverage()) {
return false;
}
if ($runtime->hasPCOV() || $runtime->hasPHPDBGCodeCoverage()) {
return true;
}
if (self::usingXdebug()) {
$mode = getenv('XDEBUG_MODE') ?: ini_get('xdebug.mode');
return $mode && in_array('coverage', explode(',', $mode), true);
}
return true;
}
/**
* If the user is using Xdebug.
*/
public static function usingXdebug(): bool
{
return (new Runtime)->hasXdebug();
}
/**
* Reports the code coverage report to the
* console and returns the result in float.
*/
public static function report(OutputInterface $output): float
{
if (! file_exists($reportPath = self::getPath())) {
if (self::usingXdebug()) {
$output->writeln(
" <fg=black;bg=yellow;options=bold> WARN </> Unable to get coverage using Xdebug. Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?</>",
);
return 0.0;
}
$output->writeln(
' <fg=black;bg=yellow;options=bold> WARN </> No coverage driver detected.</> Did you install <href=https://xdebug.org/>Xdebug</> or <href=https://github.com/krakjoe/pcov>PCOV</>?',
);
return 0.0;
}
/** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
foreach ($report->getIterator() as $file) {
if (! $file instanceof File) {
continue;
}
$dirname = dirname($file->id());
$basename = basename($file->id(), '.php');
$name = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$percentage = $file->numberOfExecutableLines() === 0
? '100.0'
: number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', '');
$uncoveredLines = '';
$percentageOfExecutedLinesAsString = $file->percentageOfExecutedLines()->asString();
if (! in_array($percentageOfExecutedLinesAsString, ['0.00%', '100.00%', '100.0%', ''], true)) {
$uncoveredLines = trim(implode(', ', self::getMissingCoverage($file)));
$uncoveredLines = sprintf('<span>%s</span>', $uncoveredLines).' <span class="text-gray"> / </span>';
}
$color = $percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow');
$truncateAt = max(1, terminal()->width() - 12);
renderUsing($output);
render(<<<HTML
<div class="flex mx-2">
<span class="truncate-{$truncateAt}">{$name}</span>
<span class="flex-1 content-repeat-[.] text-gray mx-1"></span>
<span class="text-{$color}">$uncoveredLines {$percentage}%</span>
</div>
HTML);
}
$totalCoverageAsString = $totalCoverage->asFloat() === 0.0
? '0.0'
: number_format($totalCoverage->asFloat(), 1, '.', '');
renderUsing($output);
render(<<<HTML
<div class="mx-2">
<hr class="text-gray" />
<div class="w-full text-right">
<span class="ml-1 font-bold">Total: {$totalCoverageAsString} %</span>
</div>
</div>
HTML);
return $totalCoverage->asFloat();
}
/**
* Generates an array of missing coverage on the following format:.
*
* ```
* ['11', '20..25', '50', '60..80'];
* ```
*
* @param File $file
* @return array<int, string>
*/
public static function getMissingCoverage($file): array
{
$shouldBeNewLine = true;
$eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array {
if ($tests !== []) {
$shouldBeNewLine = true;
return $array;
}
if ($shouldBeNewLine) {
$array[] = (string) $line;
$shouldBeNewLine = false;
return $array;
}
$lastKey = count($array) - 1;
if (array_key_exists($lastKey, $array) && str_contains((string) $array[$lastKey], '..')) {
[$from] = explode('..', (string) $array[$lastKey]);
$array[$lastKey] = $line > $from ? sprintf('%s..%s', $from, $line) : sprintf('%s..%s', $line, $from);
return $array;
}
$array[$lastKey] = sprintf('%s..%s', $array[$lastKey], $line);
return $array;
};
$array = [];
foreach (array_filter($file->lineCoverageData(), 'is_array') as $line => $tests) {
$array = $eachLine($array, $tests, $line);
}
return $array;
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Exceptions;
use RuntimeException;
/**
* @internal
*/
final class InvalidStyleException extends RuntimeException
{
// ...
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Exceptions;
use RuntimeException;
/**
* @internal
*/
final class ShouldNotHappen extends RuntimeException
{
/**
* @var string
*/
private const MESSAGE = 'This should not happen, please open an issue on collision repository: %s';
/**
* Creates a new Exception instance.
*/
public function __construct()
{
parent::__construct(sprintf(self::MESSAGE, 'https://github.com/nunomaduro/collision/issues/new'));
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Exceptions;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Framework\ExpectationFailedException;
use ReflectionClass;
/**
* @internal
*/
final class TestException
{
private const DIFF_SEPARATOR = '--- Expected'.PHP_EOL.'+++ Actual'.PHP_EOL.'@@ @@'.PHP_EOL;
/**
* Creates a new Exception instance.
*/
public function __construct(
private readonly Throwable $throwable,
private readonly bool $isVerbose
) {
//
}
public function getThrowable(): Throwable
{
return $this->throwable;
}
/**
* @return class-string
*/
public function getClassName(): string
{
return $this->throwable->className();
}
public function getMessage(): string
{
if ($this->throwable->className() === ExpectationFailedException::class) {
$message = $this->throwable->description();
} else {
$message = $this->throwable->message();
}
$regexes = [
'To contain' => '/Failed asserting that \'(.*)\' \[[\w-]+\]\(length: [\d]+\) contains "(.*)"/s',
'Not to contain' => '/Failed asserting that \'(.*)\' \[[\w-]+\]\(length: [\d]+\) does not contain "(.*)"/s',
];
foreach ($regexes as $key => $pattern) {
preg_match($pattern, $message, $matches, PREG_OFFSET_CAPTURE, 0);
if (count($matches) === 3) {
$message = $this->shortenMessage($matches, $key);
break;
}
}
// Diffs...
if (str_contains($message, self::DIFF_SEPARATOR)) {
$diff = '';
$lines = explode(PHP_EOL, explode(self::DIFF_SEPARATOR, $message)[1]);
foreach ($lines as $line) {
$diff .= $this->colorizeLine($line, str_starts_with($line, '-') ? 'red' : 'green').PHP_EOL;
}
$message = str_replace(explode(self::DIFF_SEPARATOR, $message)[1], $diff, $message);
$message = str_replace(self::DIFF_SEPARATOR, '', $message);
}
return $message;
}
private function shortenMessage(array $matches, string $key): string
{
$actual = $matches[1][0];
$expected = $matches[2][0];
$actualExploded = explode(PHP_EOL, $actual);
$expectedExploded = explode(PHP_EOL, $expected);
if (($countActual = count($actualExploded)) > 4 && ! $this->isVerbose) {
$actualExploded = array_slice($actualExploded, 0, 3);
}
if (($countExpected = count($expectedExploded)) > 4 && ! $this->isVerbose) {
$expectedExploded = array_slice($expectedExploded, 0, 3);
}
$actualAsString = '';
$expectedAsString = '';
foreach ($actualExploded as $line) {
$actualAsString .= PHP_EOL.$this->colorizeLine($line, 'red');
}
foreach ($expectedExploded as $line) {
$expectedAsString .= PHP_EOL.$this->colorizeLine($line, 'green');
}
if ($countActual > 4 && ! $this->isVerbose) {
$actualAsString .= PHP_EOL.$this->colorizeLine(sprintf('... (%s more lines)', $countActual - 3), 'gray');
}
if ($countExpected > 4 && ! $this->isVerbose) {
$expectedAsString .= PHP_EOL.$this->colorizeLine(sprintf('... (%s more lines)', $countExpected - 3), 'gray');
}
return implode(PHP_EOL, [
'Expected: '.ltrim($actualAsString, PHP_EOL.' '),
'',
' '.$key.': '.ltrim($expectedAsString, PHP_EOL.' '),
'',
]);
}
public function getCode(): int
{
return 0;
}
/**
* @throws \ReflectionException
*/
public function getFile(): string
{
if (! isset($this->getTrace()[0])) {
return (string) (new ReflectionClass($this->getClassName()))->getFileName();
}
return $this->getTrace()[0]['file'];
}
public function getLine(): int
{
if (! isset($this->getTrace()[0])) {
return 0;
}
return (int) $this->getTrace()[0]['line'];
}
public function getTrace(): array
{
$frames = explode("\n", $this->getTraceAsString());
$frames = array_filter($frames, fn ($trace) => $trace !== '');
return array_map(function ($trace) {
if (trim($trace) === '') {
return null;
}
$parts = explode(':', $trace);
$line = array_pop($parts);
$file = implode(':', $parts);
return [
'file' => $file,
'line' => $line,
];
}, $frames);
}
public function getTraceAsString(): string
{
return $this->throwable->stackTrace();
}
public function getPrevious(): ?self
{
if ($this->throwable->hasPrevious()) {
return new self($this->throwable->previous(), $this->isVerbose);
}
return null;
}
public function __toString()
{
return $this->getMessage();
}
private function colorizeLine(string $line, string $color): string
{
return sprintf(' <fg=%s>%s</>', $color, $line);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Exceptions;
use PHPUnit\Framework\Exception;
/**
* @internal
*/
final class TestOutcome extends Exception
{
// ...
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use Symfony\Component\Console\Output\OutputInterface;
use Whoops\Handler\Handler as AbstractHandler;
/**
* @internal
*
* @see \Tests\Unit\HandlerTest
*/
final class Handler extends AbstractHandler
{
/**
* Holds an instance of the writer.
*/
private Writer $writer;
/**
* Creates an instance of the Handler.
*/
public function __construct(?Writer $writer = null)
{
$this->writer = $writer ?: new Writer;
}
/**
* {@inheritdoc}
*/
public function handle(): int
{
$this->writer->write($this->getInspector()); // @phpstan-ignore-line
return self::QUIT;
}
/**
* {@inheritdoc}
*/
public function setOutput(OutputInterface $output): self
{
$this->writer->setOutput($output);
return $this;
}
/**
* {@inheritdoc}
*/
public function getWriter(): Writer
{
return $this->writer;
}
}

View File

@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
/**
* @internal
*/
final class Highlighter
{
public const TOKEN_DEFAULT = 'token_default';
public const TOKEN_COMMENT = 'token_comment';
public const TOKEN_STRING = 'token_string';
public const TOKEN_HTML = 'token_html';
public const TOKEN_KEYWORD = 'token_keyword';
public const ACTUAL_LINE_MARK = 'actual_line_mark';
public const LINE_NUMBER = 'line_number';
private const ARROW_SYMBOL = '>';
private const DELIMITER = '|';
private const ARROW_SYMBOL_UTF8 = '➜';
private const DELIMITER_UTF8 = '▕'; // '▶';
private const LINE_NUMBER_DIVIDER = 'line_divider';
private const MARKED_LINE_NUMBER = 'marked_line';
private const WIDTH = 3;
/**
* Holds the theme.
*/
private const THEME = [
self::TOKEN_STRING => ['light_gray'],
self::TOKEN_COMMENT => ['dark_gray', 'italic'],
self::TOKEN_KEYWORD => ['magenta', 'bold'],
self::TOKEN_DEFAULT => ['default', 'bold'],
self::TOKEN_HTML => ['blue', 'bold'],
self::ACTUAL_LINE_MARK => ['red', 'bold'],
self::LINE_NUMBER => ['dark_gray'],
self::MARKED_LINE_NUMBER => ['italic', 'bold'],
self::LINE_NUMBER_DIVIDER => ['dark_gray'],
];
private ConsoleColor $color;
private const DEFAULT_THEME = [
self::TOKEN_STRING => 'red',
self::TOKEN_COMMENT => 'yellow',
self::TOKEN_KEYWORD => 'green',
self::TOKEN_DEFAULT => 'default',
self::TOKEN_HTML => 'cyan',
self::ACTUAL_LINE_MARK => 'dark_gray',
self::LINE_NUMBER => 'dark_gray',
self::MARKED_LINE_NUMBER => 'dark_gray',
self::LINE_NUMBER_DIVIDER => 'dark_gray',
];
private string $delimiter = self::DELIMITER_UTF8;
private string $arrow = self::ARROW_SYMBOL_UTF8;
private const NO_MARK = ' ';
/**
* Creates an instance of the Highlighter.
*/
public function __construct(?ConsoleColor $color = null, bool $UTF8 = true)
{
$this->color = $color ?: new ConsoleColor;
foreach (self::DEFAULT_THEME as $name => $styles) {
if (! $this->color->hasTheme($name)) {
$this->color->addTheme($name, $styles);
}
}
foreach (self::THEME as $name => $styles) {
$this->color->addTheme($name, $styles);
}
if (! $UTF8) {
$this->delimiter = self::DELIMITER;
$this->arrow = self::ARROW_SYMBOL;
}
$this->delimiter .= ' ';
}
/**
* Highlights the provided content.
*/
public function highlight(string $content, int $line): string
{
return rtrim($this->getCodeSnippet($content, $line, 4, 4));
}
/**
* Highlights the provided content.
*/
public function getCodeSnippet(string $source, int $lineNumber, int $linesBefore = 2, int $linesAfter = 2): string
{
$tokenLines = $this->getHighlightedLines($source);
$offset = $lineNumber - $linesBefore - 1;
$offset = max($offset, 0);
$length = $linesAfter + $linesBefore + 1;
$tokenLines = array_slice($tokenLines, $offset, $length, $preserveKeys = true);
$lines = $this->colorLines($tokenLines);
return $this->lineNumbers($lines, $lineNumber);
}
private function getHighlightedLines(string $source): array
{
$source = str_replace(["\r\n", "\r"], "\n", $source);
$tokens = $this->tokenize($source);
return $this->splitToLines($tokens);
}
private function tokenize(string $source): array
{
$tokens = token_get_all($source);
$output = [];
$currentType = null;
$buffer = '';
$newType = null;
foreach ($tokens as $token) {
if (is_array($token)) {
switch ($token[0]) {
case T_WHITESPACE:
break;
case T_OPEN_TAG:
case T_OPEN_TAG_WITH_ECHO:
case T_CLOSE_TAG:
case T_STRING:
case T_VARIABLE:
// Constants
case T_DIR:
case T_FILE:
case T_METHOD_C:
case T_DNUMBER:
case T_LNUMBER:
case T_NS_C:
case T_LINE:
case T_CLASS_C:
case T_FUNC_C:
case T_TRAIT_C:
$newType = self::TOKEN_DEFAULT;
break;
case T_COMMENT:
case T_DOC_COMMENT:
$newType = self::TOKEN_COMMENT;
break;
case T_ENCAPSED_AND_WHITESPACE:
case T_CONSTANT_ENCAPSED_STRING:
$newType = self::TOKEN_STRING;
break;
case T_INLINE_HTML:
$newType = self::TOKEN_HTML;
break;
default:
$newType = self::TOKEN_KEYWORD;
}
} else {
$newType = $token === '"' ? self::TOKEN_STRING : self::TOKEN_KEYWORD;
}
if ($currentType === null) {
$currentType = $newType;
}
if ($currentType !== $newType) {
$output[] = [$currentType, $buffer];
$buffer = '';
$currentType = $newType;
}
$buffer .= is_array($token) ? $token[1] : $token;
}
if (isset($newType)) {
$output[] = [$newType, $buffer];
}
return $output;
}
private function splitToLines(array $tokens): array
{
$lines = [];
$line = [];
foreach ($tokens as $token) {
foreach (explode("\n", $token[1]) as $count => $tokenLine) {
if ($count > 0) {
$lines[] = $line;
$line = [];
}
if ($tokenLine === '') {
continue;
}
$line[] = [$token[0], $tokenLine];
}
}
$lines[] = $line;
return $lines;
}
private function colorLines(array $tokenLines): array
{
$lines = [];
foreach ($tokenLines as $lineCount => $tokenLine) {
$line = '';
foreach ($tokenLine as $token) {
[$tokenType, $tokenValue] = $token;
if ($this->color->hasTheme($tokenType)) {
$line .= $this->color->apply($tokenType, $tokenValue);
} else {
$line .= $tokenValue;
}
}
$lines[$lineCount] = $line;
}
return $lines;
}
private function lineNumbers(array $lines, ?int $markLine = null): string
{
$lineStrlen = strlen((string) ((int) array_key_last($lines) + 1));
$lineStrlen = $lineStrlen < self::WIDTH ? self::WIDTH : $lineStrlen;
$snippet = '';
$mark = ' '.$this->arrow.' ';
foreach ($lines as $i => $line) {
$coloredLineNumber = $this->coloredLineNumber(self::LINE_NUMBER, $i, $lineStrlen);
if ($markLine !== null) {
$snippet .=
($markLine === $i + 1
? $this->color->apply(self::ACTUAL_LINE_MARK, $mark)
: self::NO_MARK
);
$coloredLineNumber =
($markLine === $i + 1 ?
$this->coloredLineNumber(self::MARKED_LINE_NUMBER, $i, $lineStrlen) :
$coloredLineNumber
);
}
$snippet .= $coloredLineNumber;
$snippet .=
$this->color->apply(self::LINE_NUMBER_DIVIDER, $this->delimiter);
$snippet .= $line.PHP_EOL;
}
return $snippet;
}
private function coloredLineNumber(string $style, int $i, int $length): string
{
return $this->color->apply($style, str_pad((string) ($i + 1), $length, ' ', STR_PAD_LEFT));
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use Whoops\Run;
use Whoops\RunInterface;
/**
* @internal
*
* @see \Tests\Unit\ProviderTest
*/
final class Provider
{
/**
* Holds an instance of the Run.
*/
private RunInterface $run;
/**
* Holds an instance of the handler.
*/
private Handler $handler;
/**
* Creates a new instance of the Provider.
*/
public function __construct(?RunInterface $run = null, ?Handler $handler = null)
{
$this->run = $run ?: new Run;
$this->handler = $handler ?: new Handler;
}
/**
* Registers the current Handler as Error Handler.
*/
public function register(): self
{
$this->run->pushHandler($this->handler)
->register();
return $this;
}
/**
* Returns the handler.
*/
public function getHandler(): Handler
{
return $this->handler;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\SolutionsRepositories;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use Throwable;
/**
* @internal
*/
final class NullSolutionsRepository implements SolutionsRepository
{
/**
* {@inheritdoc}
*/
public function getFromThrowable(Throwable $throwable): array // @phpstan-ignore-line
{
return [];
}
}

View File

@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision;
use Closure;
use NunoMaduro\Collision\Contracts\RenderableOnCollisionEditor;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use NunoMaduro\Collision\Exceptions\TestException;
use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use Whoops\Exception\Frame;
use Whoops\Exception\Inspector;
/**
* @internal
*
* @see \Tests\Unit\WriterTest
*/
final class Writer
{
/**
* The number of frames if no verbosity is specified.
*/
public const VERBOSITY_NORMAL_FRAMES = 1;
/**
* Holds an instance of the solutions repository.
*/
private SolutionsRepository $solutionsRepository;
/**
* Holds an instance of the Output.
*/
private OutputInterface $output;
/**
* Holds an instance of the Argument Formatter.
*/
private ArgumentFormatter $argumentFormatter;
/**
* Holds an instance of the Highlighter.
*/
private Highlighter $highlighter;
/**
* Ignores traces where the file string matches one
* of the provided regex expressions.
*
* @var array<int, string|Closure>
*/
private array $ignore = [];
/**
* Declares whether or not the trace should appear.
*/
private bool $showTrace = true;
/**
* Declares whether or not the title should appear.
*/
private bool $showTitle = true;
/**
* Declares whether the editor should appear.
*/
private bool $showEditor = true;
/**
* Creates an instance of the writer.
*/
public function __construct(
?SolutionsRepository $solutionsRepository = null,
?OutputInterface $output = null,
?ArgumentFormatter $argumentFormatter = null,
?Highlighter $highlighter = null
) {
$this->solutionsRepository = $solutionsRepository ?: new NullSolutionsRepository;
$this->output = $output ?: new ConsoleOutput;
$this->argumentFormatter = $argumentFormatter ?: new ArgumentFormatter;
$this->highlighter = $highlighter ?: new Highlighter;
}
public function write(Inspector $inspector): void
{
$this->renderTitleAndDescription($inspector);
$frames = $this->getFrames($inspector);
$exception = $inspector->getException();
if ($exception instanceof RenderableOnCollisionEditor) {
$editorFrame = $exception->toCollisionEditor();
} else {
$editorFrame = array_shift($frames);
}
if ($this->showEditor
&& $editorFrame !== null
&& ! $exception instanceof RenderlessEditor
) {
$this->renderEditor($editorFrame);
}
$this->renderSolution($inspector);
if ($this->showTrace && ! empty($frames) && ! $exception instanceof RenderlessTrace) {
$this->renderTrace($frames);
} elseif (! $exception instanceof RenderlessEditor) {
$this->output->writeln('');
}
}
public function ignoreFilesIn(array $ignore): self
{
$this->ignore = $ignore;
return $this;
}
public function showTrace(bool $show): self
{
$this->showTrace = $show;
return $this;
}
public function showTitle(bool $show): self
{
$this->showTitle = $show;
return $this;
}
public function showEditor(bool $show): self
{
$this->showEditor = $show;
return $this;
}
public function setOutput(OutputInterface $output): self
{
$this->output = $output;
return $this;
}
public function getOutput(): OutputInterface
{
return $this->output;
}
/**
* Returns pertinent frames.
*
* @return array<int, Frame>
*/
private function getFrames(Inspector $inspector): array
{
return $inspector->getFrames()
->filter(
function ($frame) {
// If we are in verbose mode, we always
// display the full stack trace.
if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) {
return true;
}
foreach ($this->ignore as $ignore) {
if (is_string($ignore)) {
// Ensure paths are linux-style (like the ones on $this->ignore)
$sanitizedPath = (string) str_replace('\\', '/', $frame->getFile());
if (preg_match($ignore, $sanitizedPath)) {
return false;
}
}
if ($ignore instanceof Closure) {
if ($ignore($frame)) {
return false;
}
}
}
return true;
}
)
->getArray();
}
/**
* Renders the title of the exception.
*/
private function renderTitleAndDescription(Inspector $inspector): self
{
/** @var Throwable|TestException $exception */
$exception = $inspector->getException();
$message = rtrim($exception->getMessage());
$class = $exception instanceof TestException
? $exception->getClassName()
: $inspector->getExceptionName();
if ($this->showTitle) {
$this->render("<bg=red;options=bold> $class </>");
$this->output->writeln('');
}
$this->output->writeln("<fg=default;options=bold> $message</>");
return $this;
}
/**
* Renders the solution of the exception, if any.
*/
private function renderSolution(Inspector $inspector): self
{
$throwable = $inspector->getException();
$solutions = $throwable instanceof Throwable
? $this->solutionsRepository->getFromThrowable($throwable)
: []; // @phpstan-ignore-line
foreach ($solutions as $solution) {
/** @var \Spatie\Ignition\Contracts\Solution $solution */
$title = $solution->getSolutionTitle(); // @phpstan-ignore-line
$description = $solution->getSolutionDescription(); // @phpstan-ignore-line
$links = $solution->getDocumentationLinks(); // @phpstan-ignore-line
$description = trim((string) preg_replace("/\n/", "\n ", $description));
$this->render(sprintf(
'<fg=cyan;options=bold>i</> <fg=default;options=bold>%s</>: %s %s',
rtrim($title, '.'),
$description,
implode(', ', array_map(function (string $link) {
return sprintf("\n <fg=gray>%s</>", $link);
}, $links))
));
}
return $this;
}
/**
* Renders the editor containing the code that was the
* origin of the exception.
*/
private function renderEditor(Frame $frame): self
{
if ($frame->getFile() !== 'Unknown') {
$file = $this->getFileRelativePath((string) $frame->getFile());
// getLine() might return null so cast to int to get 0 instead
$line = (int) $frame->getLine();
$this->render('at <fg=green>'.$file.'</>'.':<fg=green>'.$line.'</>');
$content = $this->highlighter->highlight((string) $frame->getFileContents(), (int) $frame->getLine());
$this->output->writeln($content);
}
return $this;
}
/**
* Renders the trace of the exception.
*/
private function renderTrace(array $frames): self
{
$vendorFrames = 0;
$userFrames = 0;
if (! empty($frames)) {
$this->output->writeln(['']);
}
foreach ($frames as $i => $frame) {
if ($this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE && strpos($frame->getFile(), '/vendor/') !== false) {
$vendorFrames++;
continue;
}
if ($userFrames > self::VERBOSITY_NORMAL_FRAMES && $this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) {
break;
}
$userFrames++;
$file = $this->getFileRelativePath($frame->getFile());
$line = $frame->getLine();
$class = empty($frame->getClass()) ? '' : $frame->getClass().'::';
$function = $frame->getFunction();
$args = $this->argumentFormatter->format($frame->getArgs());
$pos = str_pad((string) ((int) $i + 1), 4, ' ');
if ($vendorFrames > 0) {
$this->output->writeln(
sprintf(" \e[2m+%s vendor frames \e[22m", $vendorFrames)
);
$vendorFrames = 0;
}
$this->render("<fg=yellow>$pos</><fg=default;options=bold>$file</>:<fg=default;options=bold>$line</>", (bool) $class && $i > 0);
if ($class) {
$this->render("<fg=gray> $class$function($args)</>", false);
}
}
if (! empty($frames)) {
$this->output->writeln(['']);
}
return $this;
}
/**
* Renders a message into the console.
*/
private function render(string $message, bool $break = true): self
{
if ($break) {
$this->output->writeln('');
}
$this->output->writeln(" $message");
return $this;
}
/**
* Returns the relative path of the given file path.
*/
private function getFileRelativePath(string $filePath): string
{
$cwd = (string) getcwd();
if (! empty($cwd)) {
return str_replace("$cwd".DIRECTORY_SEPARATOR, '', $filePath);
}
return $filePath;
}
}