First Commit

This commit is contained in:
Esteban
2026-07-03 16:27:34 +02:00
commit 7a7440daab
8955 changed files with 1117958 additions and 0 deletions
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use Illuminate\Support\ServiceProvider;
use NunoMaduro\Collision\Adapters\Laravel\Commands\TestCommand;
use NunoMaduro\Collision\Handler;
use NunoMaduro\Collision\Provider;
use NunoMaduro\Collision\SolutionsRepositories\NullSolutionsRepository;
use NunoMaduro\Collision\Writer;
use Spatie\Ignition\Contracts\SolutionProviderRepository;
/**
* @internal
*
* @final
*/
class CollisionServiceProvider extends ServiceProvider
{
/**
* {@inheritdoc}
*/
protected bool $defer = true;
/**
* Boots application services.
*/
public function boot(): void
{
$this->commands([
TestCommand::class,
]);
}
/**
* {@inheritdoc}
*/
public function register(): void
{
if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) {
$this->app->bind(Provider::class, function () {
if ($this->app->has(SolutionProviderRepository::class)) { // @phpstan-ignore-line
/** @var SolutionProviderRepository $solutionProviderRepository */
$solutionProviderRepository = $this->app->get(SolutionProviderRepository::class); // @phpstan-ignore-line
$solutionsRepository = new IgnitionSolutionsRepository($solutionProviderRepository);
} else {
$solutionsRepository = new NullSolutionsRepository;
}
$writer = new Writer($solutionsRepository);
$handler = new Handler($writer);
return new Provider(null, $handler);
});
/** @var ExceptionHandlerContract $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];
}
}
@@ -0,0 +1,391 @@
<?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 ParaTest\ParaTestCommand;
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}
{--without-cache : Indicates if cache configuration should be performed}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run the application tests';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->ignoreValidationErrors();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if ($this->option('coverage') && ! Coverage::isAvailable()) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> ERROR </> Code coverage driver not available.%s</>",
Coverage::usingXdebug()
? " Did you set <href=https://xdebug.org/docs/code_coverage#mode>Xdebug's coverage mode</>?"
: ' Did you install <href=https://xdebug.org/>Xdebug</> or <href=https://github.com/krakjoe/pcov>PCOV</>?'
));
$this->newLine();
return 1;
}
/** @var bool $usesParallel */
$usesParallel = $this->option('parallel');
if ($usesParallel && ! $this->isParallelDependenciesInstalled()) {
throw new RequirementsException('Running Collision 8.x artisan test command in parallel requires at least ParaTest (brianium/paratest) 7.x.');
}
$options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2);
$this->clearEnv();
$parallel = $this->option('parallel');
$process = (new Process(array_merge(
// Binary ...
$this->binary(),
// Arguments ...
$parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options)
),
null,
// Envs ...
$parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(),
))->setTimeout(null);
try {
$process->setTty(! $this->option('without-tty'));
} catch (RuntimeException $e) {
// $this->output->writeln('Warning: '.$e->getMessage());
}
$exitCode = 1;
try {
$exitCode = $process->run(function ($type, $line) {
$this->output->write($line);
});
} catch (ProcessSignaledException $e) {
if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) {
throw $e;
}
}
if ($exitCode === 0 && $this->option('coverage')) {
if (! $this->usingPest() && $this->option('parallel')) {
$this->newLine();
}
$hideFullCoverage = (bool) $this->option('compact');
$coverage = Coverage::report($this->output, $hideFullCoverage);
$exitCode = (int) ($coverage < $this->option('min'));
if ($exitCode === 1) {
$this->output->writeln(sprintf(
"\n <fg=white;bg=red;options=bold> FAIL </> Code coverage below expected:<fg=red;options=bold> %s %%</>. Minimum:<fg=white;options=bold> %s %%</>.",
number_format($coverage, 1),
number_format((float) $this->option('min'), 1)
));
}
}
return $exitCode;
}
/**
* Get the PHP binary to execute.
*
* @return array
*/
protected function binary()
{
if ($this->usingPest()) {
$command = $this->option('parallel') ? ['vendor/pestphp/pest/bin/pest', '--parallel'] : ['vendor/pestphp/pest/bin/pest'];
} else {
$command = $this->option('parallel') ? ['vendor/brianium/paratest/bin/paratest'] : ['vendor/phpunit/phpunit/phpunit'];
}
if ('phpdbg' === PHP_SAPI) {
return array_merge([PHP_BINARY, '-qrr'], $command);
}
return array_merge([PHP_BINARY], $command);
}
/**
* Gets the common arguments of PHPUnit and Pest.
*
* @return array
*/
protected function commonArguments()
{
$arguments = [];
if ($this->option('coverage')) {
$arguments[] = '--coverage-php';
$arguments[] = Coverage::getPath();
}
if ($this->option('ansi')) {
$arguments[] = '--colors=always';
} elseif ($this->option('no-ansi')) {
$arguments[] = '--colors=never';
} elseif ((new Console)->hasColorSupport()) {
$arguments[] = '--colors=always';
}
return $arguments;
}
/**
* Determines if Pest is being used.
*
* @return bool
*/
protected function usingPest()
{
return function_exists('\Pest\\version');
}
/**
* Get the array of arguments for running PHPUnit.
*
* @param array $options
* @return array
*/
protected function phpunitArguments($options)
{
$options = array_merge(['--no-output'], $options);
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '-q'
&& $option != '--quiet'
&& $option != '--coverage'
&& $option != '--compact'
&& $option != '--profile'
&& $option != '--ansi'
&& $option != '--no-ansi'
&& ! Str::startsWith($option, '--min');
}));
return array_merge($this->commonArguments(), ['--configuration='.$this->getConfigurationFile()], $options);
}
/**
* Get the configuration file.
*
* @return string
*/
protected function getConfigurationFile()
{
if (! file_exists($file = base_path('phpunit.xml'))) {
$file = base_path('phpunit.xml.dist');
}
return $file;
}
/**
* Get the array of arguments for running Paratest.
*
* @param array $options
* @return array
*/
protected function paratestArguments($options)
{
$options = array_values(array_filter($options, function ($option) {
return ! Str::startsWith($option, '--env=')
&& $option != '--coverage'
&& $option != '-q'
&& $option != '--quiet'
&& $option != '--ansi'
&& $option != '--no-ansi'
&& ! Str::startsWith($option, '--min')
&& ! Str::startsWith($option, '-p')
&& ! Str::startsWith($option, '--compact')
&& ! Str::startsWith($option, '--parallel')
&& ! Str::startsWith($option, '--recreate-databases')
&& ! Str::startsWith($option, '--drop-databases')
&& ! Str::startsWith($option, '--without-databases')
&& ! Str::startsWith($option, '--without-cache');
}));
$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'),
'LARAVEL_PARALLEL_TESTING_WITHOUT_CACHE' => $this->option('without-cache'),
];
}
/**
* 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(ParaTestCommand::class);
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract;
use Illuminate\Foundation\Exceptions\ReportableHandler;
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 ExceptionHandlerContract
*/
protected $appExceptionHandler;
/**
* Holds an instance of the container.
*
* @var 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 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;
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use RuntimeException;
/**
* @internal
*/
final class NotSupportedYetException extends RuntimeException implements RenderlessEditor, RenderlessTrace {}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use RuntimeException;
/**
* @internal
*/
final class RequirementsException extends RuntimeException implements RenderlessEditor, RenderlessTrace {}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace NunoMaduro\Collision\Adapters\Laravel;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
use Spatie\ErrorSolutions\Contracts\SolutionProviderRepository;
use Spatie\Ignition\Contracts\SolutionProviderRepository as IgnitionSolutionProviderRepository;
use Throwable;
/**
* @internal
*/
final class IgnitionSolutionsRepository implements SolutionsRepository
{
/**
* Holds an instance of ignition solutions provider repository.
*
* @var IgnitionSolutionProviderRepository|SolutionProviderRepository
*/
protected $solutionProviderRepository; // @phpstan-ignore-line
/**
* IgnitionSolutionsRepository constructor.
*/
public function __construct(IgnitionSolutionProviderRepository|SolutionProviderRepository $solutionProviderRepository) // @phpstan-ignore-line
{
$this->solutionProviderRepository = $solutionProviderRepository;
}
/**
* {@inheritdoc}
*/
public function getFromThrowable(Throwable $throwable): array // @phpstan-ignore-line
{
return $this->solutionProviderRepository->getSolutionsForThrowable($throwable); // @phpstan-ignore-line
}
}
@@ -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();
}
}