first commit

This commit is contained in:
Sampanna Rimal
2024-08-27 17:48:06 +05:45
commit 53c0140f58
10839 changed files with 1125847 additions and 0 deletions

View File

@ -0,0 +1 @@
.gitkeep

21
vendor/nunomaduro/collision/LICENSE.md vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Nuno Maduro <enunomaduro@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

67
vendor/nunomaduro/collision/README.md vendored Normal file
View File

@ -0,0 +1,67 @@
<p align="center">
<img src="https://raw.githubusercontent.com/nunomaduro/collision/v7.x/docs/logo.png" alt="Collision logo" width="480">
<br>
<img src="https://raw.githubusercontent.com/nunomaduro/collision/v7.x/docs/example.png" alt="Collision code example" height="300">
</p>
<p align="center">
<a href="https://github.com/nunomaduro/collision/actions"><img src="https://img.shields.io/github/actions/workflow/status/nunomaduro/collision/tests.yml?branch=v7.x&label=tests&style=round-square" alt="Build Status"></img></a>
<a href="https://scrutinizer-ci.com/g/nunomaduro/collision"><img src="https://img.shields.io/scrutinizer/g/nunomaduro/collision.svg" alt="Quality Score"></img></a>
<a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/nunomaduro/collision"><img src="https://poser.pugx.org/nunomaduro/collision/license.svg" alt="License"></a>
</p>
---
Collision was created by, and is maintained by **[Nuno Maduro](https://github.com/nunomaduro)**, and is a package designed to give you beautiful error reporting when interacting with your app through the command line.
* It's included on **[Laravel](https://laravel.com)**, the most popular free, open-source PHP framework in the world.
* Built on top of the **[Whoops](https://github.com/filp/whoops)** error handler.
* Supports [Laravel](https://github.com/laravel/laravel), [Symfony](https://symfony.com), [PHPUnit](https://github.com/sebastianbergmann/phpunit), and many other frameworks.
## Installation & Usage
> **Requires [PHP 8.1+](https://php.net/releases/)**
Require Collision using [Composer](https://getcomposer.org):
```bash
composer require nunomaduro/collision --dev
```
## Version Compatibility
Laravel | Collision | PHPUnit | Pest
:---------|:----------|:----------|:----------
6.x | 3.x | |
7.x | 4.x | |
8.x | 5.x | |
9.x | 6.x | |
10.x | 6.x | 9.x | 1.x
10.x | 7.x | 10.x | 2.x
As an example, here is how to require Collision on Laravel 8.x:
```bash
composer require nunomaduro/collision:^5.0 --dev
```
## No adapter
You need to register the handler in your code:
```php
(new \NunoMaduro\Collision\Provider)->register();
```
## Contributing
Thank you for considering to contribute to Collision. All the contribution guidelines are mentioned [here](CONTRIBUTING.md).
You can have a look at the [CHANGELOG](CHANGELOG.md) for constant updates & detailed information about the changes. You can also follow the twitter account for latest announcements or just come say hi!: [@enunomaduro](https://twitter.com/enunomaduro)
## License
Collision is an open-sourced software licensed under the [MIT license](LICENSE.md).
Logo by [Caneco](https://twitter.com/caneco).

View File

@ -0,0 +1,91 @@
{
"name": "nunomaduro/collision",
"description": "Cli error handling for console/command-line PHP applications.",
"keywords": ["console", "command-line", "php", "cli", "error", "handling", "laravel-zero", "laravel", "artisan", "symfony"],
"license": "MIT",
"support": {
"issues": "https://github.com/nunomaduro/collision/issues",
"source": "https://github.com/nunomaduro/collision"
},
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"require": {
"php": "^8.1.0",
"filp/whoops": "^2.15.3",
"nunomaduro/termwind": "^1.15.1",
"symfony/console": "^6.3.4"
},
"conflict": {
"laravel/framework": ">=11.0.0"
},
"require-dev": {
"brianium/paratest": "^7.3.0",
"laravel/framework": "^10.28.0",
"laravel/pint": "^1.13.3",
"laravel/sail": "^1.25.0",
"laravel/sanctum": "^3.3.1",
"laravel/tinker": "^2.8.2",
"nunomaduro/larastan": "^2.6.4",
"orchestra/testbench-core": "^8.13.0",
"pestphp/pest": "^2.23.2",
"phpunit/phpunit": "^10.4.1",
"sebastian/environment": "^6.0.1",
"spatie/laravel-ignition": "^2.3.1"
},
"autoload-dev": {
"psr-4": {
"Tests\\Printer\\": "tests/Printer",
"Tests\\Unit\\": "tests/Unit",
"Tests\\FakeProgram\\": "tests/FakeProgram",
"Tests\\": "tests/LaravelApp/tests",
"App\\": "tests/LaravelApp/app/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
"psr-4": {
"NunoMaduro\\Collision\\": "src/"
},
"files": [
"./src/Adapters/Phpunit/Autoload.php"
]
},
"config": {
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"laravel": {
"providers": [
"NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
]
}
},
"scripts": {
"lint": "pint -v",
"test:lint": "pint --test -v",
"test:types": "phpstan analyse --ansi",
"test:unit:phpunit": [
"@putenv XDEBUG_MODE=coverage",
"phpunit --colors=always"
],
"test:unit:pest": [
"@putenv XDEBUG_MODE=coverage",
"pest --colors=always -v"
],
"test": [
"@test:lint",
"@test:types",
"@test:unit:phpunit",
"@test:unit:pest"
]
}
}

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)) {
/** @var SolutionProviderRepository $solutionProviderRepository */
$solutionProviderRepository = $this->app->get(SolutionProviderRepository::class);
$solutionsRepository = new IgnitionSolutionsRepository($solutionProviderRepository);
} else {
$solutionsRepository = new NullSolutionsRepository();
}
$writer = new Writer($solutionsRepository);
$handler = new Handler($writer);
return new Provider(null, $handler);
});
/** @var \Illuminate\Contracts\Debug\ExceptionHandler $appExceptionHandler */
$appExceptionHandler = $this->app->make(ExceptionHandlerContract::class);
$this->app->singleton(
ExceptionHandlerContract::class,
function ($app) use ($appExceptionHandler) {
return new ExceptionHandler($app, $appExceptionHandler);
}
);
}
}
/**
* {@inheritdoc}
*/
public function provides()
{
return [Provider::class];
}
}

View File

@ -0,0 +1,398 @@
<?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 PHPUnit\Runner\Version;
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()
{
$phpunitVersion = Version::id();
if ($phpunitVersion[0].$phpunitVersion[1] !== '10') {
throw new RequirementsException('Running Collision 7.x artisan test command requires at least PHPUnit 10.x.');
}
$laravelVersion = \Illuminate\Foundation\Application::VERSION;
if ($laravelVersion[0].$laravelVersion[1] !== '10') { // @phpstan-ignore-line
throw new RequirementsException('Running Collision 7.x artisan test command requires at least Laravel 10.x.');
}
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 7.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')) { // @phpstan-ignore-line
$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,16 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use RuntimeException;
/**
* @internal
*/
final class NotSupportedYetException extends RuntimeException implements RenderlessEditor, RenderlessTrace
{
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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,432 @@
<?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);
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,555 @@
<?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 = preg_replace('/`([^`]+)`/', '<span class="text-white">$1</span>', $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));
}
/**
* @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,324 @@
<?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 = '';
/**
* Creates a new TestResult instance.
*/
private function __construct(string $id, string $testCaseName, string $description, string $type, string $icon, string $compactIcon, string $color, string $compactColor, 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->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();
} 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 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();
} else {
$testCaseName = $test->className();
}
if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) {
$description = $test->testDox()->prettifiedMethodName();
} else {
$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;
$value = $matches[2];
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,12 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Contracts;
/**
* @internal
*/
interface RenderlessEditor
{
}

View File

@ -0,0 +1,12 @@
<?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;
}

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 \'(.*)\' contains "(.*)"\./s',
'Not to contain' => '/Failed asserting that \'(.*)\' 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
{
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();
$description = $solution->getSolutionDescription();
$links = $solution->getDocumentationLinks();
$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;
}
}