├── .temp └── .gitkeep ├── src ├── Contracts │ ├── RenderlessEditor.php │ ├── RenderlessTrace.php │ ├── RenderableOnCollisionEditor.php │ ├── SolutionsRepository.php │ └── Adapters │ │ └── Phpunit │ │ └── HasPrintableTestCaseName.php ├── Exceptions │ ├── TestOutcome.php │ ├── InvalidStyleException.php │ ├── ShouldNotHappen.php │ └── TestException.php ├── Adapters │ ├── Phpunit │ │ ├── Autoload.php │ │ ├── Support │ │ │ └── ResultReflection.php │ │ ├── Printers │ │ │ ├── ReportablePrinter.php │ │ │ └── DefaultPrinter.php │ │ ├── Subscribers │ │ │ ├── Subscriber.php │ │ │ └── EnsurePrinterIsRegisteredSubscriber.php │ │ ├── ConfigureIO.php │ │ ├── State.php │ │ ├── TestResult.php │ │ └── Style.php │ └── Laravel │ │ ├── Exceptions │ │ ├── RequirementsException.php │ │ └── NotSupportedYetException.php │ │ ├── Inspector.php │ │ ├── IgnitionSolutionsRepository.php │ │ ├── CollisionServiceProvider.php │ │ ├── ExceptionHandler.php │ │ └── Commands │ │ └── TestCommand.php ├── SolutionsRepositories │ └── NullSolutionsRepository.php ├── Provider.php ├── Handler.php ├── ArgumentFormatter.php ├── ConsoleColor.php ├── Coverage.php ├── Highlighter.php └── Writer.php ├── LICENSE.md ├── composer.json └── README.md /.temp/.gitkeep: -------------------------------------------------------------------------------- 1 | .gitkeep -------------------------------------------------------------------------------- /src/Contracts/RenderlessEditor.php: -------------------------------------------------------------------------------- 1 | = 10) { 11 | EnsurePrinterIsRegisteredSubscriber::register(); 12 | } 13 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Exceptions/RequirementsException.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getFromThrowable(Throwable $throwable): array; // @phpstan-ignore-line 21 | } 22 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/Support/ResultReflection.php: -------------------------------------------------------------------------------- 1 | $this->numberOfTests)->call($testResult); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/SolutionsRepositories/NullSolutionsRepository.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace NunoMaduro\Collision\Adapters\Laravel; 15 | 16 | use Whoops\Exception\Inspector as BaseInspector; 17 | 18 | /** 19 | * @internal 20 | */ 21 | final class Inspector extends BaseInspector 22 | { 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | protected function getTrace($e) 27 | { 28 | return $e->getTrace(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Contracts/Adapters/Phpunit/HasPrintableTestCaseName.php: -------------------------------------------------------------------------------- 1 | printer->$name(...$arguments); 31 | } catch (Throwable $throwable) { 32 | $this->printer->report($throwable); 33 | } 34 | 35 | exit(1); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/Subscribers/Subscriber.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace NunoMaduro\Collision\Adapters\Phpunit\Subscribers; 15 | 16 | use NunoMaduro\Collision\Adapters\Phpunit\Printers\ReportablePrinter; 17 | 18 | /** 19 | * @internal 20 | */ 21 | abstract class Subscriber 22 | { 23 | /** 24 | * The printer instance. 25 | */ 26 | private ReportablePrinter $printer; 27 | 28 | /** 29 | * Creates a new subscriber. 30 | */ 31 | public function __construct(ReportablePrinter $printer) 32 | { 33 | $this->printer = $printer; 34 | } 35 | 36 | /** 37 | * Returns the printer instance. 38 | */ 39 | protected function printer(): ReportablePrinter 40 | { 41 | return $this->printer; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Nuno Maduro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/ConfigureIO.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace NunoMaduro\Collision\Adapters\Phpunit; 15 | 16 | use ReflectionObject; 17 | use Symfony\Component\Console\Application; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Output\Output; 20 | 21 | /** 22 | * @internal 23 | */ 24 | final class ConfigureIO 25 | { 26 | /** 27 | * Configures both given input and output with 28 | * options from the environment. 29 | * 30 | * @throws \ReflectionException 31 | */ 32 | public static function of(InputInterface $input, Output $output): void 33 | { 34 | $application = new Application(); 35 | $reflector = new ReflectionObject($application); 36 | $method = $reflector->getMethod('configureIO'); 37 | $method->setAccessible(true); 38 | $method->invoke($application, $input, $output); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Provider.php: -------------------------------------------------------------------------------- 1 | run = $run ?: new Run(); 33 | $this->handler = $handler ?: new Handler(); 34 | } 35 | 36 | /** 37 | * Registers the current Handler as Error Handler. 38 | */ 39 | public function register(): self 40 | { 41 | $this->run->pushHandler($this->handler) 42 | ->register(); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Returns the handler. 49 | */ 50 | public function getHandler(): Handler 51 | { 52 | return $this->handler; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Handler.php: -------------------------------------------------------------------------------- 1 | writer = $writer ?: new Writer(); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function handle(): int 34 | { 35 | $this->writer->write($this->getInspector()); // @phpstan-ignore-line 36 | 37 | return self::QUIT; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function setOutput(OutputInterface $output): self 44 | { 45 | $this->writer->setOutput($output); 46 | 47 | return $this; 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function getWriter(): Writer 54 | { 55 | return $this->writer; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ArgumentFormatter.php: -------------------------------------------------------------------------------- 1 | self::MAX_STRING_LENGTH ? mb_substr($argument, 0, self::MAX_STRING_LENGTH).'...' : $argument).'"'; 24 | break; 25 | case is_array($argument): 26 | $associative = array_keys($argument) !== range(0, count($argument) - 1); 27 | if ($recursive && $associative && count($argument) <= 5) { 28 | $result[] = '['.$this->format($argument, false).']'; 29 | } 30 | break; 31 | case is_object($argument): 32 | $class = get_class($argument); 33 | $result[] = "Object($class)"; 34 | break; 35 | } 36 | } 37 | 38 | return implode(', ', $result); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/IgnitionSolutionsRepository.php: -------------------------------------------------------------------------------- 1 | solutionProviderRepository = $solutionProviderRepository; 30 | } 31 | 32 | /** 33 | * {@inheritdoc} 34 | */ 35 | public function getFromThrowable(Throwable $throwable): array // @phpstan-ignore-line 36 | { 37 | return $this->solutionProviderRepository->getSolutionsForThrowable($throwable); // @phpstan-ignore-line 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/CollisionServiceProvider.php: -------------------------------------------------------------------------------- 1 | commands([ 34 | TestCommand::class, 35 | ]); 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function register(): void 42 | { 43 | if ($this->app->runningInConsole() && ! $this->app->runningUnitTests()) { 44 | $this->app->bind(Provider::class, function () { 45 | if ($this->app->has(SolutionProviderRepository::class)) { // @phpstan-ignore-line 46 | /** @var SolutionProviderRepository $solutionProviderRepository */ 47 | $solutionProviderRepository = $this->app->get(SolutionProviderRepository::class); // @phpstan-ignore-line 48 | 49 | $solutionsRepository = new IgnitionSolutionsRepository($solutionProviderRepository); 50 | } else { 51 | $solutionsRepository = new NullSolutionsRepository(); 52 | } 53 | 54 | $writer = new Writer($solutionsRepository); 55 | $handler = new Handler($writer); 56 | 57 | return new Provider(null, $handler); 58 | }); 59 | 60 | /** @var \Illuminate\Contracts\Debug\ExceptionHandler $appExceptionHandler */ 61 | $appExceptionHandler = $this->app->make(ExceptionHandlerContract::class); 62 | 63 | $this->app->singleton( 64 | ExceptionHandlerContract::class, 65 | function ($app) use ($appExceptionHandler) { 66 | return new ExceptionHandler($app, $appExceptionHandler); 67 | } 68 | ); 69 | } 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function provides() 76 | { 77 | return [Provider::class]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nunomaduro/collision", 3 | "description": "Cli error handling for console/command-line PHP applications.", 4 | "keywords": ["console", "command-line", "php", "cli", "error", "handling", "laravel-zero", "laravel", "artisan", "symfony"], 5 | "license": "MIT", 6 | "support": { 7 | "issues": "https://github.com/nunomaduro/collision/issues", 8 | "source": "https://github.com/nunomaduro/collision" 9 | }, 10 | "authors": [ 11 | { 12 | "name": "Nuno Maduro", 13 | "email": "enunomaduro@gmail.com" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.2.0", 18 | "filp/whoops": "^2.15.4", 19 | "nunomaduro/termwind": "^2.0.1", 20 | "symfony/console": "^7.1.2" 21 | }, 22 | "conflict": { 23 | "laravel/framework": "<11.0.0 || >=12.0.0", 24 | "phpunit/phpunit": "<10.5.1 || >=12.0.0" 25 | }, 26 | "require-dev": { 27 | "laravel/framework": "^11.14.0", 28 | "laravel/pint": "^1.16.1", 29 | "laravel/tinker": "^2.9.0", 30 | "laravel/sail": "^1.30.1", 31 | "laravel/sanctum": "^4.0.2", 32 | "larastan/larastan": "^2.9.7", 33 | "orchestra/testbench-core": "^9.1.8", 34 | "pestphp/pest": "^2.34.8 || ^3.0.0", 35 | "sebastian/environment": "^6.1.0 || ^7.0.0" 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Tests\\Printer\\": "tests/Printer", 40 | "Tests\\Unit\\": "tests/Unit", 41 | "Tests\\FakeProgram\\": "tests/FakeProgram", 42 | "Tests\\": "tests/LaravelApp/tests", 43 | "App\\": "tests/LaravelApp/app/" 44 | } 45 | }, 46 | "minimum-stability": "dev", 47 | "prefer-stable": true, 48 | "autoload": { 49 | "psr-4": { 50 | "NunoMaduro\\Collision\\": "src/" 51 | }, 52 | "files": [ 53 | "./src/Adapters/Phpunit/Autoload.php" 54 | ] 55 | }, 56 | "config": { 57 | "preferred-install": "dist", 58 | "sort-packages": true, 59 | "allow-plugins": { 60 | "pestphp/pest-plugin": true 61 | } 62 | }, 63 | "extra": { 64 | "laravel": { 65 | "providers": [ 66 | "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" 67 | ] 68 | }, 69 | "branch-alias": { 70 | "dev-8.x": "8.x-dev" 71 | } 72 | }, 73 | "scripts": { 74 | "lint": "pint -v", 75 | "test:lint": "pint --test -v", 76 | "test:types": "phpstan analyse --ansi", 77 | "test:unit:phpunit": [ 78 | "@putenv XDEBUG_MODE=coverage", 79 | "phpunit --colors=always" 80 | ], 81 | "test:unit:pest": [ 82 | "@putenv XDEBUG_MODE=coverage", 83 | "pest --colors=always -v" 84 | ], 85 | "test": [ 86 | "@test:lint", 87 | "@test:types", 88 | "@test:unit:phpunit", 89 | "@test:unit:pest" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Collision logo 3 |
4 | Collision code example 5 |

6 | 7 |

8 | Build Status 9 | Quality Score 10 | Total Downloads 11 | License 12 |

13 | 14 | --- 15 | 16 | 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. 17 | 18 | * It's included on **[Laravel](https://laravel.com)**, the most popular free, open-source PHP framework in the world. 19 | * Built on top of the **[Whoops](https://github.com/filp/whoops)** error handler. 20 | * Supports [Laravel](https://github.com/laravel/laravel), [Symfony](https://symfony.com), [PHPUnit](https://github.com/sebastianbergmann/phpunit), and many other frameworks. 21 | 22 | ## Installation & Usage 23 | 24 | > **Requires [PHP 8.2+](https://php.net/releases/)** 25 | 26 | Require Collision using [Composer](https://getcomposer.org): 27 | 28 | ```bash 29 | composer require nunomaduro/collision --dev 30 | ``` 31 | 32 | ## Version Compatibility 33 | 34 | Laravel | Collision | PHPUnit | Pest 35 | :---------|:----------|:----------|:---------- 36 | 6.x | 3.x | | 37 | 7.x | 4.x | | 38 | 8.x | 5.x | | 39 | 9.x | 6.x | | 40 | 10.x | 6.x | 9.x | 1.x 41 | 10.x | 7.x | 10.x | 2.x 42 | 11.x | 8.x | 10.x | 2.x 43 | 11.x | 8.x | 11.x | 3.x 44 | 45 | As an example, here is how to require Collision on Laravel 8.x: 46 | 47 | ```bash 48 | composer require nunomaduro/collision:^5.0 --dev 49 | ``` 50 | 51 | ## No adapter 52 | 53 | You need to register the handler in your code: 54 | 55 | ```php 56 | (new \NunoMaduro\Collision\Provider)->register(); 57 | ``` 58 | 59 | ## Contributing 60 | 61 | Thank you for considering to contribute to Collision. All the contribution guidelines are mentioned [here](CONTRIBUTING.md). 62 | 63 | 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) 64 | 65 | ## License 66 | 67 | Collision is an open-sourced software licensed under the [MIT license](LICENSE.md). 68 | 69 | Logo by [Caneco](https://twitter.com/caneco). 70 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/ExceptionHandler.php: -------------------------------------------------------------------------------- 1 | container = $container; 38 | $this->appExceptionHandler = $appExceptionHandler; 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function report(Throwable $e) 45 | { 46 | $this->appExceptionHandler->report($e); 47 | } 48 | 49 | /** 50 | * {@inheritdoc} 51 | */ 52 | public function render($request, Throwable $e) 53 | { 54 | return $this->appExceptionHandler->render($request, $e); 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function renderForConsole($output, Throwable $e) 61 | { 62 | if ($e instanceof SymfonyConsoleExceptionInterface) { 63 | $this->appExceptionHandler->renderForConsole($output, $e); 64 | } else { 65 | /** @var Provider $provider */ 66 | $provider = $this->container->make(Provider::class); 67 | 68 | $handler = $provider->register() 69 | ->getHandler() 70 | ->setOutput($output); 71 | 72 | $handler->setInspector((new Inspector($e))); 73 | 74 | $handler->handle(); 75 | } 76 | } 77 | 78 | /** 79 | * Determine if the exception should be reported. 80 | * 81 | * @return bool 82 | */ 83 | public function shouldReport(Throwable $e) 84 | { 85 | return $this->appExceptionHandler->shouldReport($e); 86 | } 87 | 88 | /** 89 | * Register a reportable callback. 90 | * 91 | * @return \Illuminate\Foundation\Exceptions\ReportableHandler 92 | */ 93 | public function reportable(callable $reportUsing) 94 | { 95 | return $this->appExceptionHandler->reportable($reportUsing); 96 | } 97 | 98 | /** 99 | * Register a renderable callback. 100 | * 101 | * @return $this 102 | */ 103 | public function renderable(callable $renderUsing) 104 | { 105 | $this->appExceptionHandler->renderable($renderUsing); 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * Do not report duplicate exceptions. 112 | * 113 | * @return $this 114 | */ 115 | public function dontReportDuplicates() 116 | { 117 | $this->appExceptionHandler->dontReportDuplicates(); 118 | 119 | return $this; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Exceptions/TestException.php: -------------------------------------------------------------------------------- 1 | throwable; 31 | } 32 | 33 | /** 34 | * @return class-string 35 | */ 36 | public function getClassName(): string 37 | { 38 | return $this->throwable->className(); 39 | } 40 | 41 | public function getMessage(): string 42 | { 43 | if ($this->throwable->className() === ExpectationFailedException::class) { 44 | $message = $this->throwable->description(); 45 | } else { 46 | $message = $this->throwable->message(); 47 | } 48 | 49 | $regexes = [ 50 | 'To contain' => '/Failed asserting that \'(.*)\' \[[\w-]+\]\(length: [\d]+\) contains "(.*)"/s', 51 | 'Not to contain' => '/Failed asserting that \'(.*)\' \[[\w-]+\]\(length: [\d]+\) does not contain "(.*)"/s', 52 | ]; 53 | 54 | foreach ($regexes as $key => $pattern) { 55 | preg_match($pattern, $message, $matches, PREG_OFFSET_CAPTURE, 0); 56 | 57 | if (count($matches) === 3) { 58 | $message = $this->shortenMessage($matches, $key); 59 | 60 | break; 61 | } 62 | } 63 | 64 | // Diffs... 65 | if (str_contains($message, self::DIFF_SEPARATOR)) { 66 | $diff = ''; 67 | $lines = explode(PHP_EOL, explode(self::DIFF_SEPARATOR, $message)[1]); 68 | 69 | foreach ($lines as $line) { 70 | $diff .= $this->colorizeLine($line, str_starts_with($line, '-') ? 'red' : 'green').PHP_EOL; 71 | } 72 | 73 | $message = str_replace(explode(self::DIFF_SEPARATOR, $message)[1], $diff, $message); 74 | $message = str_replace(self::DIFF_SEPARATOR, '', $message); 75 | } 76 | 77 | return $message; 78 | } 79 | 80 | private function shortenMessage(array $matches, string $key): string 81 | { 82 | $actual = $matches[1][0]; 83 | $expected = $matches[2][0]; 84 | 85 | $actualExploded = explode(PHP_EOL, $actual); 86 | $expectedExploded = explode(PHP_EOL, $expected); 87 | 88 | if (($countActual = count($actualExploded)) > 4 && ! $this->isVerbose) { 89 | $actualExploded = array_slice($actualExploded, 0, 3); 90 | } 91 | 92 | if (($countExpected = count($expectedExploded)) > 4 && ! $this->isVerbose) { 93 | $expectedExploded = array_slice($expectedExploded, 0, 3); 94 | } 95 | 96 | $actualAsString = ''; 97 | $expectedAsString = ''; 98 | foreach ($actualExploded as $line) { 99 | $actualAsString .= PHP_EOL.$this->colorizeLine($line, 'red'); 100 | } 101 | 102 | foreach ($expectedExploded as $line) { 103 | $expectedAsString .= PHP_EOL.$this->colorizeLine($line, 'green'); 104 | } 105 | 106 | if ($countActual > 4 && ! $this->isVerbose) { 107 | $actualAsString .= PHP_EOL.$this->colorizeLine(sprintf('... (%s more lines)', $countActual - 3), 'gray'); 108 | } 109 | 110 | if ($countExpected > 4 && ! $this->isVerbose) { 111 | $expectedAsString .= PHP_EOL.$this->colorizeLine(sprintf('... (%s more lines)', $countExpected - 3), 'gray'); 112 | } 113 | 114 | return implode(PHP_EOL, [ 115 | 'Expected: '.ltrim($actualAsString, PHP_EOL.' '), 116 | '', 117 | ' '.$key.': '.ltrim($expectedAsString, PHP_EOL.' '), 118 | '', 119 | ]); 120 | } 121 | 122 | public function getCode(): int 123 | { 124 | return 0; 125 | } 126 | 127 | /** 128 | * @throws \ReflectionException 129 | */ 130 | public function getFile(): string 131 | { 132 | if (! isset($this->getTrace()[0])) { 133 | return (string) (new ReflectionClass($this->getClassName()))->getFileName(); 134 | } 135 | 136 | return $this->getTrace()[0]['file']; 137 | } 138 | 139 | public function getLine(): int 140 | { 141 | if (! isset($this->getTrace()[0])) { 142 | return 0; 143 | } 144 | 145 | return (int) $this->getTrace()[0]['line']; 146 | } 147 | 148 | public function getTrace(): array 149 | { 150 | $frames = explode("\n", $this->getTraceAsString()); 151 | 152 | $frames = array_filter($frames, fn ($trace) => $trace !== ''); 153 | 154 | return array_map(function ($trace) { 155 | if (trim($trace) === '') { 156 | return null; 157 | } 158 | 159 | $parts = explode(':', $trace); 160 | $line = array_pop($parts); 161 | $file = implode(':', $parts); 162 | 163 | return [ 164 | 'file' => $file, 165 | 'line' => $line, 166 | ]; 167 | }, $frames); 168 | } 169 | 170 | public function getTraceAsString(): string 171 | { 172 | return $this->throwable->stackTrace(); 173 | } 174 | 175 | public function getPrevious(): ?self 176 | { 177 | if ($this->throwable->hasPrevious()) { 178 | return new self($this->throwable->previous(), $this->isVerbose); 179 | } 180 | 181 | return null; 182 | } 183 | 184 | public function __toString() 185 | { 186 | return $this->getMessage(); 187 | } 188 | 189 | private function colorizeLine(string $line, string $color): string 190 | { 191 | return sprintf(' %s', $color, $line); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/ConsoleColor.php: -------------------------------------------------------------------------------- 1 | null, 31 | 'bold' => '1', 32 | 'dark' => '2', 33 | 'italic' => '3', 34 | 'underline' => '4', 35 | 'blink' => '5', 36 | 'reverse' => '7', 37 | 'concealed' => '8', 38 | 39 | 'default' => '39', 40 | 'black' => '30', 41 | 'red' => '31', 42 | 'green' => '32', 43 | 'yellow' => '33', 44 | 'blue' => '34', 45 | 'magenta' => '35', 46 | 'cyan' => '36', 47 | 'light_gray' => '37', 48 | 49 | 'dark_gray' => '90', 50 | 'light_red' => '91', 51 | 'light_green' => '92', 52 | 'light_yellow' => '93', 53 | 'light_blue' => '94', 54 | 'light_magenta' => '95', 55 | 'light_cyan' => '96', 56 | 'white' => '97', 57 | 58 | 'bg_default' => '49', 59 | 'bg_black' => '40', 60 | 'bg_red' => '41', 61 | 'bg_green' => '42', 62 | 'bg_yellow' => '43', 63 | 'bg_blue' => '44', 64 | 'bg_magenta' => '45', 65 | 'bg_cyan' => '46', 66 | 'bg_light_gray' => '47', 67 | 68 | 'bg_dark_gray' => '100', 69 | 'bg_light_red' => '101', 70 | 'bg_light_green' => '102', 71 | 'bg_light_yellow' => '103', 72 | 'bg_light_blue' => '104', 73 | 'bg_light_magenta' => '105', 74 | 'bg_light_cyan' => '106', 75 | 'bg_white' => '107', 76 | ]; 77 | 78 | private array $themes = []; 79 | 80 | /** 81 | * @throws InvalidStyleException 82 | * @throws InvalidArgumentException 83 | */ 84 | public function apply(array|string $style, string $text): string 85 | { 86 | if (! $this->isStyleForced() && ! $this->isSupported()) { 87 | return $text; 88 | } 89 | 90 | if (is_string($style)) { 91 | $style = [$style]; 92 | } 93 | if (! is_array($style)) { 94 | throw new InvalidArgumentException('Style must be string or array.'); 95 | } 96 | 97 | $sequences = []; 98 | 99 | foreach ($style as $s) { 100 | if (isset($this->themes[$s])) { 101 | $sequences = array_merge($sequences, $this->themeSequence($s)); 102 | } elseif ($this->isValidStyle($s)) { 103 | $sequences[] = $this->styleSequence($s); 104 | } else { 105 | throw new ShouldNotHappen(); 106 | } 107 | } 108 | 109 | $sequences = array_filter($sequences, function ($val) { 110 | return $val !== null; 111 | }); 112 | 113 | if (empty($sequences)) { 114 | return $text; 115 | } 116 | 117 | return $this->escSequence(implode(';', $sequences)).$text.$this->escSequence(self::RESET_STYLE); 118 | } 119 | 120 | public function setForceStyle(bool $forceStyle): void 121 | { 122 | $this->forceStyle = $forceStyle; 123 | } 124 | 125 | public function isStyleForced(): bool 126 | { 127 | return $this->forceStyle; 128 | } 129 | 130 | public function setThemes(array $themes): void 131 | { 132 | $this->themes = []; 133 | foreach ($themes as $name => $styles) { 134 | $this->addTheme($name, $styles); 135 | } 136 | } 137 | 138 | public function addTheme(string $name, array|string $styles): void 139 | { 140 | if (is_string($styles)) { 141 | $styles = [$styles]; 142 | } 143 | if (! is_array($styles)) { 144 | throw new InvalidArgumentException('Style must be string or array.'); 145 | } 146 | 147 | foreach ($styles as $style) { 148 | if (! $this->isValidStyle($style)) { 149 | throw new InvalidStyleException($style); 150 | } 151 | } 152 | 153 | $this->themes[$name] = $styles; 154 | } 155 | 156 | public function getThemes(): array 157 | { 158 | return $this->themes; 159 | } 160 | 161 | public function hasTheme(string $name): bool 162 | { 163 | return isset($this->themes[$name]); 164 | } 165 | 166 | public function removeTheme(string $name): void 167 | { 168 | unset($this->themes[$name]); 169 | } 170 | 171 | public function isSupported(): bool 172 | { 173 | // The COLLISION_FORCE_COLORS variable is for internal purposes only 174 | if (getenv('COLLISION_FORCE_COLORS') !== false) { 175 | return true; 176 | } 177 | 178 | if (DIRECTORY_SEPARATOR === '\\') { 179 | return getenv('ANSICON') !== false || getenv('ConEmuANSI') === 'ON'; 180 | } 181 | 182 | return function_exists('posix_isatty') && @posix_isatty(STDOUT); 183 | } 184 | 185 | public function are256ColorsSupported(): bool 186 | { 187 | if (DIRECTORY_SEPARATOR === '\\') { 188 | return function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(STDOUT); 189 | } 190 | 191 | return strpos((string) getenv('TERM'), '256color') !== false; 192 | } 193 | 194 | public function getPossibleStyles(): array 195 | { 196 | return array_keys(self::STYLES); 197 | } 198 | 199 | private function themeSequence(string $name): array 200 | { 201 | $sequences = []; 202 | foreach ($this->themes[$name] as $style) { 203 | $sequences[] = $this->styleSequence($style); 204 | } 205 | 206 | return $sequences; 207 | } 208 | 209 | private function styleSequence(string $style): ?string 210 | { 211 | if (array_key_exists($style, self::STYLES)) { 212 | return self::STYLES[$style]; 213 | } 214 | 215 | if (! $this->are256ColorsSupported()) { 216 | return null; 217 | } 218 | 219 | preg_match(self::COLOR256_REGEXP, $style, $matches); 220 | 221 | $type = $matches[1] === 'bg_' ? self::BACKGROUND : self::FOREGROUND; 222 | $value = $matches[2]; 223 | 224 | return "$type;5;$value"; 225 | } 226 | 227 | private function isValidStyle(string $style): bool 228 | { 229 | return array_key_exists($style, self::STYLES) || preg_match(self::COLOR256_REGEXP, $style); 230 | } 231 | 232 | private function escSequence(string|int $value): string 233 | { 234 | return "\033[{$value}m"; 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/Coverage.php: -------------------------------------------------------------------------------- 1 | canCollectCodeCoverage()) { 42 | return false; 43 | } 44 | 45 | if ($runtime->hasPCOV() || $runtime->hasPHPDBGCodeCoverage()) { 46 | return true; 47 | } 48 | 49 | if (self::usingXdebug()) { 50 | $mode = getenv('XDEBUG_MODE') ?: ini_get('xdebug.mode'); 51 | 52 | return $mode && in_array('coverage', explode(',', $mode), true); 53 | } 54 | 55 | return true; 56 | } 57 | 58 | /** 59 | * If the user is using Xdebug. 60 | */ 61 | public static function usingXdebug(): bool 62 | { 63 | return (new Runtime())->hasXdebug(); 64 | } 65 | 66 | /** 67 | * Reports the code coverage report to the 68 | * console and returns the result in float. 69 | */ 70 | public static function report(OutputInterface $output): float 71 | { 72 | if (! file_exists($reportPath = self::getPath())) { 73 | if (self::usingXdebug()) { 74 | $output->writeln( 75 | " WARN Unable to get coverage using Xdebug. Did you set Xdebug's coverage mode?", 76 | ); 77 | 78 | return 0.0; 79 | } 80 | 81 | $output->writeln( 82 | ' WARN No coverage driver detected. Did you install Xdebug or PCOV?', 83 | ); 84 | 85 | return 0.0; 86 | } 87 | 88 | /** @var CodeCoverage $codeCoverage */ 89 | $codeCoverage = require $reportPath; 90 | unlink($reportPath); 91 | 92 | $totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); 93 | 94 | /** @var Directory $report */ 95 | $report = $codeCoverage->getReport(); 96 | 97 | foreach ($report->getIterator() as $file) { 98 | if (! $file instanceof File) { 99 | continue; 100 | } 101 | $dirname = dirname($file->id()); 102 | $basename = basename($file->id(), '.php'); 103 | 104 | $name = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [ 105 | $dirname, 106 | $basename, 107 | ]); 108 | 109 | $percentage = $file->numberOfExecutableLines() === 0 110 | ? '100.0' 111 | : number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', ''); 112 | 113 | $uncoveredLines = ''; 114 | 115 | $percentageOfExecutedLinesAsString = $file->percentageOfExecutedLines()->asString(); 116 | 117 | if (! in_array($percentageOfExecutedLinesAsString, ['0.00%', '100.00%', '100.0%', ''], true)) { 118 | $uncoveredLines = trim(implode(', ', self::getMissingCoverage($file))); 119 | $uncoveredLines = sprintf('%s', $uncoveredLines).' / '; 120 | } 121 | 122 | $color = $percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'); 123 | 124 | $truncateAt = max(1, terminal()->width() - 12); 125 | 126 | renderUsing($output); 127 | render(<< 129 | {$name} 130 | 131 | $uncoveredLines {$percentage}% 132 | 133 | HTML); 134 | } 135 | 136 | $totalCoverageAsString = $totalCoverage->asFloat() === 0.0 137 | ? '0.0' 138 | : number_format($totalCoverage->asFloat(), 1, '.', ''); 139 | 140 | renderUsing($output); 141 | render(<< 143 |
144 |
145 | Total: {$totalCoverageAsString} % 146 |
147 | 148 | HTML); 149 | 150 | return $totalCoverage->asFloat(); 151 | } 152 | 153 | /** 154 | * Generates an array of missing coverage on the following format:. 155 | * 156 | * ``` 157 | * ['11', '20..25', '50', '60..80']; 158 | * ``` 159 | * 160 | * @param File $file 161 | * @return array 162 | */ 163 | public static function getMissingCoverage($file): array 164 | { 165 | $shouldBeNewLine = true; 166 | 167 | $eachLine = function (array $array, array $tests, int $line) use (&$shouldBeNewLine): array { 168 | if ($tests !== []) { 169 | $shouldBeNewLine = true; 170 | 171 | return $array; 172 | } 173 | 174 | if ($shouldBeNewLine) { 175 | $array[] = (string) $line; 176 | $shouldBeNewLine = false; 177 | 178 | return $array; 179 | } 180 | 181 | $lastKey = count($array) - 1; 182 | 183 | if (array_key_exists($lastKey, $array) && str_contains((string) $array[$lastKey], '..')) { 184 | [$from] = explode('..', (string) $array[$lastKey]); 185 | $array[$lastKey] = $line > $from ? sprintf('%s..%s', $from, $line) : sprintf('%s..%s', $line, $from); 186 | 187 | return $array; 188 | } 189 | 190 | $array[$lastKey] = sprintf('%s..%s', $array[$lastKey], $line); 191 | 192 | return $array; 193 | }; 194 | 195 | $array = []; 196 | foreach (array_filter($file->lineCoverageData(), 'is_array') as $line => $tests) { 197 | $array = $eachLine($array, $tests, $line); 198 | } 199 | 200 | return $array; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/State.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | public array $suiteTests = []; 22 | 23 | /** 24 | * The current test case class. 25 | */ 26 | public ?string $testCaseName; 27 | 28 | /** 29 | * The current test case tests. 30 | * 31 | * @var array 32 | */ 33 | public array $testCaseTests = []; 34 | 35 | /** 36 | * The current test case tests. 37 | * 38 | * @var array 39 | */ 40 | public array $toBePrintedCaseTests = []; 41 | 42 | /** 43 | * Header printed. 44 | */ 45 | public bool $headerPrinted = false; 46 | 47 | /** 48 | * The state constructor. 49 | */ 50 | public function __construct() 51 | { 52 | $this->testCaseName = ''; 53 | } 54 | 55 | /** 56 | * Checks if the given test already contains a result. 57 | */ 58 | public function existsInTestCase(Test $test): bool 59 | { 60 | return isset($this->testCaseTests[$test->id()]); 61 | } 62 | 63 | /** 64 | * Adds the given test to the State. 65 | */ 66 | public function add(TestResult $test): void 67 | { 68 | $this->testCaseName = $test->testCaseName; 69 | 70 | $levels = array_flip([ 71 | TestResult::PASS, 72 | TestResult::RUNS, 73 | TestResult::TODO, 74 | TestResult::SKIPPED, 75 | TestResult::WARN, 76 | TestResult::NOTICE, 77 | TestResult::DEPRECATED, 78 | TestResult::RISKY, 79 | TestResult::INCOMPLETE, 80 | TestResult::FAIL, 81 | ]); 82 | 83 | if (isset($this->testCaseTests[$test->id])) { 84 | $existing = $this->testCaseTests[$test->id]; 85 | 86 | if ($levels[$existing->type] >= $levels[$test->type]) { 87 | return; 88 | } 89 | } 90 | 91 | $this->testCaseTests[$test->id] = $test; 92 | $this->toBePrintedCaseTests[$test->id] = $test; 93 | 94 | $this->suiteTests[$test->id] = $test; 95 | } 96 | 97 | /** 98 | * Sets the duration of the given test, and returns the test result. 99 | */ 100 | public function setDuration(Test $test, float $duration): TestResult 101 | { 102 | $result = $this->testCaseTests[$test->id()]; 103 | 104 | $result->setDuration($duration); 105 | 106 | return $result; 107 | } 108 | 109 | /** 110 | * Gets the test case title. 111 | */ 112 | public function getTestCaseTitle(): string 113 | { 114 | foreach ($this->testCaseTests as $test) { 115 | if ($test->type === TestResult::FAIL) { 116 | return 'FAIL'; 117 | } 118 | } 119 | 120 | foreach ($this->testCaseTests as $test) { 121 | if ($test->type !== TestResult::PASS && $test->type !== TestResult::TODO && $test->type !== TestResult::DEPRECATED && $test->type !== TestResult::NOTICE) { 122 | return 'WARN'; 123 | } 124 | } 125 | 126 | foreach ($this->testCaseTests as $test) { 127 | if ($test->type === TestResult::NOTICE) { 128 | return 'NOTI'; 129 | } 130 | } 131 | 132 | foreach ($this->testCaseTests as $test) { 133 | if ($test->type === TestResult::DEPRECATED) { 134 | return 'DEPR'; 135 | } 136 | } 137 | 138 | if ($this->todosCount() > 0 && (count($this->testCaseTests) === $this->todosCount())) { 139 | return 'TODO'; 140 | } 141 | 142 | return 'PASS'; 143 | } 144 | 145 | /** 146 | * Gets the number of tests that are todos. 147 | */ 148 | public function todosCount(): int 149 | { 150 | return count(array_values(array_filter($this->testCaseTests, function (TestResult $test): bool { 151 | return $test->type === TestResult::TODO; 152 | }))); 153 | } 154 | 155 | /** 156 | * Gets the test case title color. 157 | */ 158 | public function getTestCaseFontColor(): string 159 | { 160 | if ($this->getTestCaseTitleColor() === 'blue') { 161 | return 'white'; 162 | } 163 | 164 | return $this->getTestCaseTitle() === 'FAIL' ? 'default' : 'black'; 165 | } 166 | 167 | /** 168 | * Gets the test case title color. 169 | */ 170 | public function getTestCaseTitleColor(): string 171 | { 172 | foreach ($this->testCaseTests as $test) { 173 | if ($test->type === TestResult::FAIL) { 174 | return 'red'; 175 | } 176 | } 177 | 178 | foreach ($this->testCaseTests as $test) { 179 | if ($test->type !== TestResult::PASS && $test->type !== TestResult::TODO && $test->type !== TestResult::DEPRECATED) { 180 | return 'yellow'; 181 | } 182 | } 183 | 184 | foreach ($this->testCaseTests as $test) { 185 | if ($test->type === TestResult::DEPRECATED) { 186 | return 'yellow'; 187 | } 188 | } 189 | 190 | foreach ($this->testCaseTests as $test) { 191 | if ($test->type === TestResult::TODO) { 192 | return 'blue'; 193 | } 194 | } 195 | 196 | return 'green'; 197 | } 198 | 199 | /** 200 | * Returns the number of tests on the current test case. 201 | */ 202 | public function testCaseTestsCount(): int 203 | { 204 | return count($this->testCaseTests); 205 | } 206 | 207 | /** 208 | * Returns the number of tests on the complete test suite. 209 | */ 210 | public function testSuiteTestsCount(): int 211 | { 212 | return count($this->suiteTests); 213 | } 214 | 215 | /** 216 | * Checks if the given test case is different from the current one. 217 | */ 218 | public function testCaseHasChanged(TestMethod $test): bool 219 | { 220 | return self::getPrintableTestCaseName($test) !== $this->testCaseName; 221 | } 222 | 223 | /** 224 | * Moves the an new test case. 225 | */ 226 | public function moveTo(TestMethod $test): void 227 | { 228 | $this->testCaseName = self::getPrintableTestCaseName($test); 229 | 230 | $this->testCaseTests = []; 231 | 232 | $this->headerPrinted = false; 233 | } 234 | 235 | /** 236 | * Foreach test in the test case. 237 | */ 238 | public function eachTestCaseTests(callable $callback): void 239 | { 240 | foreach ($this->toBePrintedCaseTests as $test) { 241 | $callback($test); 242 | } 243 | 244 | $this->toBePrintedCaseTests = []; 245 | } 246 | 247 | public function countTestsInTestSuiteBy(string $type): int 248 | { 249 | return count(array_filter($this->suiteTests, function (TestResult $testResult) use ($type) { 250 | return $testResult->type === $type; 251 | })); 252 | } 253 | 254 | /** 255 | * Returns the printable test case name from the given `TestCase`. 256 | */ 257 | public static function getPrintableTestCaseName(TestMethod $test): string 258 | { 259 | $className = explode('::', $test->id())[0]; 260 | 261 | if (is_subclass_of($className, HasPrintableTestCaseName::class)) { 262 | return $className::getPrintableTestCaseName(); 263 | } 264 | 265 | return $className; 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/Highlighter.php: -------------------------------------------------------------------------------- 1 | '; 27 | 28 | private const DELIMITER = '|'; 29 | 30 | private const ARROW_SYMBOL_UTF8 = '➜'; 31 | 32 | private const DELIMITER_UTF8 = '▕'; // '▶'; 33 | 34 | private const LINE_NUMBER_DIVIDER = 'line_divider'; 35 | 36 | private const MARKED_LINE_NUMBER = 'marked_line'; 37 | 38 | private const WIDTH = 3; 39 | 40 | /** 41 | * Holds the theme. 42 | */ 43 | private const THEME = [ 44 | self::TOKEN_STRING => ['light_gray'], 45 | self::TOKEN_COMMENT => ['dark_gray', 'italic'], 46 | self::TOKEN_KEYWORD => ['magenta', 'bold'], 47 | self::TOKEN_DEFAULT => ['default', 'bold'], 48 | self::TOKEN_HTML => ['blue', 'bold'], 49 | 50 | self::ACTUAL_LINE_MARK => ['red', 'bold'], 51 | self::LINE_NUMBER => ['dark_gray'], 52 | self::MARKED_LINE_NUMBER => ['italic', 'bold'], 53 | self::LINE_NUMBER_DIVIDER => ['dark_gray'], 54 | ]; 55 | 56 | private ConsoleColor $color; 57 | 58 | private const DEFAULT_THEME = [ 59 | self::TOKEN_STRING => 'red', 60 | self::TOKEN_COMMENT => 'yellow', 61 | self::TOKEN_KEYWORD => 'green', 62 | self::TOKEN_DEFAULT => 'default', 63 | self::TOKEN_HTML => 'cyan', 64 | 65 | self::ACTUAL_LINE_MARK => 'dark_gray', 66 | self::LINE_NUMBER => 'dark_gray', 67 | self::MARKED_LINE_NUMBER => 'dark_gray', 68 | self::LINE_NUMBER_DIVIDER => 'dark_gray', 69 | ]; 70 | 71 | private string $delimiter = self::DELIMITER_UTF8; 72 | 73 | private string $arrow = self::ARROW_SYMBOL_UTF8; 74 | 75 | private const NO_MARK = ' '; 76 | 77 | /** 78 | * Creates an instance of the Highlighter. 79 | */ 80 | public function __construct(?ConsoleColor $color = null, bool $UTF8 = true) 81 | { 82 | $this->color = $color ?: new ConsoleColor(); 83 | 84 | foreach (self::DEFAULT_THEME as $name => $styles) { 85 | if (! $this->color->hasTheme($name)) { 86 | $this->color->addTheme($name, $styles); 87 | } 88 | } 89 | 90 | foreach (self::THEME as $name => $styles) { 91 | $this->color->addTheme($name, $styles); 92 | } 93 | if (! $UTF8) { 94 | $this->delimiter = self::DELIMITER; 95 | $this->arrow = self::ARROW_SYMBOL; 96 | } 97 | $this->delimiter .= ' '; 98 | } 99 | 100 | /** 101 | * Highlights the provided content. 102 | */ 103 | public function highlight(string $content, int $line): string 104 | { 105 | return rtrim($this->getCodeSnippet($content, $line, 4, 4)); 106 | } 107 | 108 | /** 109 | * Highlights the provided content. 110 | */ 111 | public function getCodeSnippet(string $source, int $lineNumber, int $linesBefore = 2, int $linesAfter = 2): string 112 | { 113 | $tokenLines = $this->getHighlightedLines($source); 114 | 115 | $offset = $lineNumber - $linesBefore - 1; 116 | $offset = max($offset, 0); 117 | $length = $linesAfter + $linesBefore + 1; 118 | $tokenLines = array_slice($tokenLines, $offset, $length, $preserveKeys = true); 119 | 120 | $lines = $this->colorLines($tokenLines); 121 | 122 | return $this->lineNumbers($lines, $lineNumber); 123 | } 124 | 125 | private function getHighlightedLines(string $source): array 126 | { 127 | $source = str_replace(["\r\n", "\r"], "\n", $source); 128 | $tokens = $this->tokenize($source); 129 | 130 | return $this->splitToLines($tokens); 131 | } 132 | 133 | private function tokenize(string $source): array 134 | { 135 | $tokens = token_get_all($source); 136 | 137 | $output = []; 138 | $currentType = null; 139 | $buffer = ''; 140 | $newType = null; 141 | 142 | foreach ($tokens as $token) { 143 | if (is_array($token)) { 144 | switch ($token[0]) { 145 | case T_WHITESPACE: 146 | break; 147 | 148 | case T_OPEN_TAG: 149 | case T_OPEN_TAG_WITH_ECHO: 150 | case T_CLOSE_TAG: 151 | case T_STRING: 152 | case T_VARIABLE: 153 | // Constants 154 | case T_DIR: 155 | case T_FILE: 156 | case T_METHOD_C: 157 | case T_DNUMBER: 158 | case T_LNUMBER: 159 | case T_NS_C: 160 | case T_LINE: 161 | case T_CLASS_C: 162 | case T_FUNC_C: 163 | case T_TRAIT_C: 164 | $newType = self::TOKEN_DEFAULT; 165 | break; 166 | 167 | case T_COMMENT: 168 | case T_DOC_COMMENT: 169 | $newType = self::TOKEN_COMMENT; 170 | break; 171 | 172 | case T_ENCAPSED_AND_WHITESPACE: 173 | case T_CONSTANT_ENCAPSED_STRING: 174 | $newType = self::TOKEN_STRING; 175 | break; 176 | 177 | case T_INLINE_HTML: 178 | $newType = self::TOKEN_HTML; 179 | break; 180 | 181 | default: 182 | $newType = self::TOKEN_KEYWORD; 183 | } 184 | } else { 185 | $newType = $token === '"' ? self::TOKEN_STRING : self::TOKEN_KEYWORD; 186 | } 187 | 188 | if ($currentType === null) { 189 | $currentType = $newType; 190 | } 191 | 192 | if ($currentType !== $newType) { 193 | $output[] = [$currentType, $buffer]; 194 | $buffer = ''; 195 | $currentType = $newType; 196 | } 197 | 198 | $buffer .= is_array($token) ? $token[1] : $token; 199 | } 200 | 201 | if (isset($newType)) { 202 | $output[] = [$newType, $buffer]; 203 | } 204 | 205 | return $output; 206 | } 207 | 208 | private function splitToLines(array $tokens): array 209 | { 210 | $lines = []; 211 | 212 | $line = []; 213 | foreach ($tokens as $token) { 214 | foreach (explode("\n", $token[1]) as $count => $tokenLine) { 215 | if ($count > 0) { 216 | $lines[] = $line; 217 | $line = []; 218 | } 219 | 220 | if ($tokenLine === '') { 221 | continue; 222 | } 223 | 224 | $line[] = [$token[0], $tokenLine]; 225 | } 226 | } 227 | 228 | $lines[] = $line; 229 | 230 | return $lines; 231 | } 232 | 233 | private function colorLines(array $tokenLines): array 234 | { 235 | $lines = []; 236 | foreach ($tokenLines as $lineCount => $tokenLine) { 237 | $line = ''; 238 | foreach ($tokenLine as $token) { 239 | [$tokenType, $tokenValue] = $token; 240 | if ($this->color->hasTheme($tokenType)) { 241 | $line .= $this->color->apply($tokenType, $tokenValue); 242 | } else { 243 | $line .= $tokenValue; 244 | } 245 | } 246 | $lines[$lineCount] = $line; 247 | } 248 | 249 | return $lines; 250 | } 251 | 252 | private function lineNumbers(array $lines, ?int $markLine = null): string 253 | { 254 | $lineStrlen = strlen((string) ((int) array_key_last($lines) + 1)); 255 | $lineStrlen = $lineStrlen < self::WIDTH ? self::WIDTH : $lineStrlen; 256 | $snippet = ''; 257 | $mark = ' '.$this->arrow.' '; 258 | foreach ($lines as $i => $line) { 259 | $coloredLineNumber = $this->coloredLineNumber(self::LINE_NUMBER, $i, $lineStrlen); 260 | 261 | if ($markLine !== null) { 262 | $snippet .= 263 | ($markLine === $i + 1 264 | ? $this->color->apply(self::ACTUAL_LINE_MARK, $mark) 265 | : self::NO_MARK 266 | ); 267 | 268 | $coloredLineNumber = 269 | ($markLine === $i + 1 ? 270 | $this->coloredLineNumber(self::MARKED_LINE_NUMBER, $i, $lineStrlen) : 271 | $coloredLineNumber 272 | ); 273 | } 274 | $snippet .= $coloredLineNumber; 275 | 276 | $snippet .= 277 | $this->color->apply(self::LINE_NUMBER_DIVIDER, $this->delimiter); 278 | 279 | $snippet .= $line.PHP_EOL; 280 | } 281 | 282 | return $snippet; 283 | } 284 | 285 | private function coloredLineNumber(string $style, int $i, int $length): string 286 | { 287 | return $this->color->apply($style, str_pad((string) ($i + 1), $length, ' ', STR_PAD_LEFT)); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/TestResult.php: -------------------------------------------------------------------------------- 1 | id = $id; 75 | $this->testCaseName = $testCaseName; 76 | $this->description = $description; 77 | $this->type = $type; 78 | $this->icon = $icon; 79 | $this->compactIcon = $compactIcon; 80 | $this->color = $color; 81 | $this->compactColor = $compactColor; 82 | $this->notes = $notes; 83 | $this->issues = $issues; 84 | $this->prs = $prs; 85 | $this->throwable = $throwable; 86 | 87 | $this->duration = 0.0; 88 | 89 | $asWarning = $this->type === TestResult::WARN 90 | || $this->type === TestResult::RISKY 91 | || $this->type === TestResult::SKIPPED 92 | || $this->type === TestResult::DEPRECATED 93 | || $this->type === TestResult::NOTICE 94 | || $this->type === TestResult::INCOMPLETE; 95 | 96 | if ($throwable instanceof Throwable && $asWarning) { 97 | if (in_array($this->type, [TestResult::DEPRECATED, TestResult::NOTICE])) { 98 | foreach (explode("\n", $throwable->stackTrace()) as $line) { 99 | if (strpos($line, 'vendor/nunomaduro/collision') === false) { 100 | $this->warningSource = str_replace(getcwd().'/', '', $line); 101 | 102 | break; 103 | } 104 | } 105 | } 106 | 107 | $this->warning .= trim((string) preg_replace("/\r|\n/", ' ', $throwable->message())); 108 | 109 | // pest specific 110 | $this->warning = str_replace('__pest_evaluable_', '', $this->warning); 111 | $this->warning = str_replace('This test depends on "P\\', 'This test depends on "', $this->warning); 112 | } 113 | } 114 | 115 | /** 116 | * Sets the telemetry information. 117 | */ 118 | public function setDuration(float $duration): void 119 | { 120 | $this->duration = $duration; 121 | } 122 | 123 | /** 124 | * Creates a new test from the given test case. 125 | */ 126 | public static function fromTestCase(Test $test, string $type, ?Throwable $throwable = null): self 127 | { 128 | if (! $test instanceof TestMethod) { 129 | throw new ShouldNotHappen(); 130 | } 131 | 132 | if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { 133 | $testCaseName = $test->className()::getPrintableTestCaseName(); 134 | } else { 135 | $testCaseName = $test->className(); 136 | } 137 | 138 | $description = self::makeDescription($test); 139 | 140 | $icon = self::makeIcon($type); 141 | 142 | $compactIcon = self::makeCompactIcon($type); 143 | 144 | $color = self::makeColor($type); 145 | 146 | $compactColor = self::makeCompactColor($type); 147 | 148 | $notes = method_exists($test->className(), 'getPrintableTestCaseMethodNotes') ? $test->className()::getPrintableTestCaseMethodNotes() : []; 149 | $issues = method_exists($test->className(), 'getPrintableTestCaseMethodIssues') ? $test->className()::getPrintableTestCaseMethodIssues() : []; 150 | $prs = method_exists($test->className(), 'getPrintableTestCaseMethodPrs') ? $test->className()::getPrintableTestCaseMethodPrs() : []; 151 | 152 | return new self($test->id(), $testCaseName, $description, $type, $icon, $compactIcon, $color, $compactColor, $notes, $issues, $prs, $throwable); 153 | } 154 | 155 | /** 156 | * Creates a new test from the given Pest Parallel Test Case. 157 | */ 158 | public static function fromPestParallelTestCase(Test $test, string $type, ?Throwable $throwable = null): self 159 | { 160 | if (! $test instanceof TestMethod) { 161 | throw new ShouldNotHappen(); 162 | } 163 | 164 | if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { 165 | $testCaseName = $test->className()::getPrintableTestCaseName(); 166 | } else { 167 | $testCaseName = $test->className(); 168 | } 169 | 170 | if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { 171 | $description = $test->testDox()->prettifiedMethodName(); 172 | } else { 173 | $description = self::makeDescription($test); 174 | } 175 | 176 | $icon = self::makeIcon($type); 177 | 178 | $compactIcon = self::makeCompactIcon($type); 179 | 180 | $color = self::makeColor($type); 181 | 182 | $compactColor = self::makeCompactColor($type); 183 | 184 | return new self($test->id(), $testCaseName, $description, $type, $icon, $compactIcon, $color, $compactColor, [], [], [], $throwable); 185 | } 186 | 187 | /** 188 | * Creates a new test from the given test case. 189 | */ 190 | public static function fromBeforeFirstTestMethodErrored(BeforeFirstTestMethodErrored $event): self 191 | { 192 | if (is_subclass_of($event->testClassName(), HasPrintableTestCaseName::class)) { 193 | $testCaseName = $event->testClassName()::getPrintableTestCaseName(); 194 | } else { 195 | $testCaseName = $event->testClassName(); 196 | } 197 | 198 | $description = ''; 199 | 200 | $icon = self::makeIcon(self::FAIL); 201 | 202 | $compactIcon = self::makeCompactIcon(self::FAIL); 203 | 204 | $color = self::makeColor(self::FAIL); 205 | 206 | $compactColor = self::makeCompactColor(self::FAIL); 207 | 208 | return new self($testCaseName, $testCaseName, $description, self::FAIL, $icon, $compactIcon, $color, $compactColor, [], [], [], $event->throwable()); 209 | } 210 | 211 | /** 212 | * Get the test case description. 213 | */ 214 | public static function makeDescription(TestMethod $test): string 215 | { 216 | if (is_subclass_of($test->className(), HasPrintableTestCaseName::class)) { 217 | return $test->className()::getLatestPrintableTestCaseMethodName(); 218 | } 219 | 220 | $name = $test->name(); 221 | 222 | // First, lets replace underscore by spaces. 223 | $name = str_replace('_', ' ', $name); 224 | 225 | // Then, replace upper cases by spaces. 226 | $name = (string) preg_replace('/([A-Z])/', ' $1', $name); 227 | 228 | // Finally, if it starts with `test`, we remove it. 229 | $name = (string) preg_replace('/^test/', '', $name); 230 | 231 | // Removes spaces 232 | $name = trim($name); 233 | 234 | // Lower case everything 235 | $name = mb_strtolower($name); 236 | 237 | return $name; 238 | } 239 | 240 | /** 241 | * Get the test case icon. 242 | */ 243 | public static function makeIcon(string $type): string 244 | { 245 | switch ($type) { 246 | case self::FAIL: 247 | return '⨯'; 248 | case self::SKIPPED: 249 | return '-'; 250 | case self::DEPRECATED: 251 | case self::WARN: 252 | case self::RISKY: 253 | case self::NOTICE: 254 | return '!'; 255 | case self::INCOMPLETE: 256 | return '…'; 257 | case self::TODO: 258 | return '↓'; 259 | case self::RUNS: 260 | return '•'; 261 | default: 262 | return '✓'; 263 | } 264 | } 265 | 266 | /** 267 | * Get the test case compact icon. 268 | */ 269 | public static function makeCompactIcon(string $type): string 270 | { 271 | switch ($type) { 272 | case self::FAIL: 273 | return '⨯'; 274 | case self::SKIPPED: 275 | return 's'; 276 | case self::DEPRECATED: 277 | case self::NOTICE: 278 | case self::WARN: 279 | case self::RISKY: 280 | return '!'; 281 | case self::INCOMPLETE: 282 | return 'i'; 283 | case self::TODO: 284 | return 't'; 285 | case self::RUNS: 286 | return '•'; 287 | default: 288 | return '.'; 289 | } 290 | } 291 | 292 | /** 293 | * Get the test case compact color. 294 | */ 295 | public static function makeCompactColor(string $type): string 296 | { 297 | switch ($type) { 298 | case self::FAIL: 299 | return 'red'; 300 | case self::DEPRECATED: 301 | case self::NOTICE: 302 | case self::SKIPPED: 303 | case self::INCOMPLETE: 304 | case self::RISKY: 305 | case self::WARN: 306 | case self::RUNS: 307 | return 'yellow'; 308 | case self::TODO: 309 | return 'cyan'; 310 | default: 311 | return 'gray'; 312 | } 313 | } 314 | 315 | /** 316 | * Get the test case color. 317 | */ 318 | public static function makeColor(string $type): string 319 | { 320 | switch ($type) { 321 | case self::TODO: 322 | return 'cyan'; 323 | case self::FAIL: 324 | return 'red'; 325 | case self::DEPRECATED: 326 | case self::NOTICE: 327 | case self::SKIPPED: 328 | case self::INCOMPLETE: 329 | case self::RISKY: 330 | case self::WARN: 331 | case self::RUNS: 332 | return 'yellow'; 333 | default: 334 | return 'green'; 335 | } 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/Writer.php: -------------------------------------------------------------------------------- 1 | 57 | */ 58 | private array $ignore = []; 59 | 60 | /** 61 | * Declares whether or not the trace should appear. 62 | */ 63 | private bool $showTrace = true; 64 | 65 | /** 66 | * Declares whether or not the title should appear. 67 | */ 68 | private bool $showTitle = true; 69 | 70 | /** 71 | * Declares whether the editor should appear. 72 | */ 73 | private bool $showEditor = true; 74 | 75 | /** 76 | * Creates an instance of the writer. 77 | */ 78 | public function __construct( 79 | ?SolutionsRepository $solutionsRepository = null, 80 | ?OutputInterface $output = null, 81 | ?ArgumentFormatter $argumentFormatter = null, 82 | ?Highlighter $highlighter = null 83 | ) { 84 | $this->solutionsRepository = $solutionsRepository ?: new NullSolutionsRepository(); 85 | $this->output = $output ?: new ConsoleOutput(); 86 | $this->argumentFormatter = $argumentFormatter ?: new ArgumentFormatter(); 87 | $this->highlighter = $highlighter ?: new Highlighter(); 88 | } 89 | 90 | public function write(Inspector $inspector): void 91 | { 92 | $this->renderTitleAndDescription($inspector); 93 | 94 | $frames = $this->getFrames($inspector); 95 | 96 | $exception = $inspector->getException(); 97 | 98 | if ($exception instanceof RenderableOnCollisionEditor) { 99 | $editorFrame = $exception->toCollisionEditor(); 100 | } else { 101 | $editorFrame = array_shift($frames); 102 | } 103 | 104 | if ($this->showEditor 105 | && $editorFrame !== null 106 | && ! $exception instanceof RenderlessEditor 107 | ) { 108 | $this->renderEditor($editorFrame); 109 | } 110 | 111 | $this->renderSolution($inspector); 112 | 113 | if ($this->showTrace && ! empty($frames) && ! $exception instanceof RenderlessTrace) { 114 | $this->renderTrace($frames); 115 | } elseif (! $exception instanceof RenderlessEditor) { 116 | $this->output->writeln(''); 117 | } 118 | } 119 | 120 | public function ignoreFilesIn(array $ignore): self 121 | { 122 | $this->ignore = $ignore; 123 | 124 | return $this; 125 | } 126 | 127 | public function showTrace(bool $show): self 128 | { 129 | $this->showTrace = $show; 130 | 131 | return $this; 132 | } 133 | 134 | public function showTitle(bool $show): self 135 | { 136 | $this->showTitle = $show; 137 | 138 | return $this; 139 | } 140 | 141 | public function showEditor(bool $show): self 142 | { 143 | $this->showEditor = $show; 144 | 145 | return $this; 146 | } 147 | 148 | public function setOutput(OutputInterface $output): self 149 | { 150 | $this->output = $output; 151 | 152 | return $this; 153 | } 154 | 155 | public function getOutput(): OutputInterface 156 | { 157 | return $this->output; 158 | } 159 | 160 | /** 161 | * Returns pertinent frames. 162 | * 163 | * @return array 164 | */ 165 | private function getFrames(Inspector $inspector): array 166 | { 167 | return $inspector->getFrames() 168 | ->filter( 169 | function ($frame) { 170 | // If we are in verbose mode, we always 171 | // display the full stack trace. 172 | if ($this->output->getVerbosity() >= OutputInterface::VERBOSITY_VERBOSE) { 173 | return true; 174 | } 175 | 176 | foreach ($this->ignore as $ignore) { 177 | if (is_string($ignore)) { 178 | // Ensure paths are linux-style (like the ones on $this->ignore) 179 | $sanitizedPath = (string) str_replace('\\', '/', $frame->getFile()); 180 | if (preg_match($ignore, $sanitizedPath)) { 181 | return false; 182 | } 183 | } 184 | 185 | if ($ignore instanceof Closure) { 186 | if ($ignore($frame)) { 187 | return false; 188 | } 189 | } 190 | } 191 | 192 | return true; 193 | } 194 | ) 195 | ->getArray(); 196 | } 197 | 198 | /** 199 | * Renders the title of the exception. 200 | */ 201 | private function renderTitleAndDescription(Inspector $inspector): self 202 | { 203 | /** @var Throwable|TestException $exception */ 204 | $exception = $inspector->getException(); 205 | $message = rtrim($exception->getMessage()); 206 | $class = $exception instanceof TestException 207 | ? $exception->getClassName() 208 | : $inspector->getExceptionName(); 209 | 210 | if ($this->showTitle) { 211 | $this->render(" $class "); 212 | $this->output->writeln(''); 213 | } 214 | 215 | $this->output->writeln(" $message"); 216 | 217 | return $this; 218 | } 219 | 220 | /** 221 | * Renders the solution of the exception, if any. 222 | */ 223 | private function renderSolution(Inspector $inspector): self 224 | { 225 | $throwable = $inspector->getException(); 226 | 227 | $solutions = $throwable instanceof Throwable 228 | ? $this->solutionsRepository->getFromThrowable($throwable) 229 | : []; // @phpstan-ignore-line 230 | 231 | foreach ($solutions as $solution) { 232 | /** @var \Spatie\Ignition\Contracts\Solution $solution */ 233 | $title = $solution->getSolutionTitle(); // @phpstan-ignore-line 234 | $description = $solution->getSolutionDescription(); // @phpstan-ignore-line 235 | $links = $solution->getDocumentationLinks(); // @phpstan-ignore-line 236 | 237 | $description = trim((string) preg_replace("/\n/", "\n ", $description)); 238 | 239 | $this->render(sprintf( 240 | 'i %s: %s %s', 241 | rtrim($title, '.'), 242 | $description, 243 | implode(', ', array_map(function (string $link) { 244 | return sprintf("\n %s", $link); 245 | }, $links)) 246 | )); 247 | } 248 | 249 | return $this; 250 | } 251 | 252 | /** 253 | * Renders the editor containing the code that was the 254 | * origin of the exception. 255 | */ 256 | private function renderEditor(Frame $frame): self 257 | { 258 | if ($frame->getFile() !== 'Unknown') { 259 | $file = $this->getFileRelativePath((string) $frame->getFile()); 260 | 261 | // getLine() might return null so cast to int to get 0 instead 262 | $line = (int) $frame->getLine(); 263 | $this->render('at '.$file.''.':'.$line.''); 264 | 265 | $content = $this->highlighter->highlight((string) $frame->getFileContents(), (int) $frame->getLine()); 266 | 267 | $this->output->writeln($content); 268 | } 269 | 270 | return $this; 271 | } 272 | 273 | /** 274 | * Renders the trace of the exception. 275 | */ 276 | private function renderTrace(array $frames): self 277 | { 278 | $vendorFrames = 0; 279 | $userFrames = 0; 280 | 281 | if (! empty($frames)) { 282 | $this->output->writeln(['']); 283 | } 284 | 285 | foreach ($frames as $i => $frame) { 286 | if ($this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE && strpos($frame->getFile(), '/vendor/') !== false) { 287 | $vendorFrames++; 288 | 289 | continue; 290 | } 291 | 292 | if ($userFrames > self::VERBOSITY_NORMAL_FRAMES && $this->output->getVerbosity() < OutputInterface::VERBOSITY_VERBOSE) { 293 | break; 294 | } 295 | 296 | $userFrames++; 297 | 298 | $file = $this->getFileRelativePath($frame->getFile()); 299 | $line = $frame->getLine(); 300 | $class = empty($frame->getClass()) ? '' : $frame->getClass().'::'; 301 | $function = $frame->getFunction(); 302 | $args = $this->argumentFormatter->format($frame->getArgs()); 303 | $pos = str_pad((string) ((int) $i + 1), 4, ' '); 304 | 305 | if ($vendorFrames > 0) { 306 | $this->output->writeln( 307 | sprintf(" \e[2m+%s vendor frames \e[22m", $vendorFrames) 308 | ); 309 | $vendorFrames = 0; 310 | } 311 | 312 | $this->render("$pos$file:$line", (bool) $class && $i > 0); 313 | if ($class) { 314 | $this->render(" $class$function($args)", false); 315 | } 316 | } 317 | 318 | if (! empty($frames)) { 319 | $this->output->writeln(['']); 320 | } 321 | 322 | return $this; 323 | } 324 | 325 | /** 326 | * Renders a message into the console. 327 | */ 328 | private function render(string $message, bool $break = true): self 329 | { 330 | if ($break) { 331 | $this->output->writeln(''); 332 | } 333 | 334 | $this->output->writeln(" $message"); 335 | 336 | return $this; 337 | } 338 | 339 | /** 340 | * Returns the relative path of the given file path. 341 | */ 342 | private function getFileRelativePath(string $filePath): string 343 | { 344 | $cwd = (string) getcwd(); 345 | 346 | if (! empty($cwd)) { 347 | return str_replace("$cwd".DIRECTORY_SEPARATOR, '', $filePath); 348 | } 349 | 350 | return $filePath; 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/Adapters/Laravel/Commands/TestCommand.php: -------------------------------------------------------------------------------- 1 | ignoreValidationErrors(); 64 | } 65 | 66 | /** 67 | * Execute the console command. 68 | * 69 | * @return mixed 70 | */ 71 | public function handle() 72 | { 73 | if ($this->option('coverage') && ! Coverage::isAvailable()) { 74 | $this->output->writeln(sprintf( 75 | "\n ERROR Code coverage driver not available.%s", 76 | Coverage::usingXdebug() 77 | ? " Did you set Xdebug's coverage mode?" 78 | : ' Did you install Xdebug or PCOV?' 79 | )); 80 | 81 | $this->newLine(); 82 | 83 | return 1; 84 | } 85 | 86 | /** @var bool $usesParallel */ 87 | $usesParallel = $this->option('parallel'); 88 | 89 | if ($usesParallel && ! $this->isParallelDependenciesInstalled()) { 90 | throw new RequirementsException('Running Collision 8.x artisan test command in parallel requires at least ParaTest (brianium/paratest) 7.x.'); 91 | } 92 | 93 | $options = array_slice($_SERVER['argv'], $this->option('without-tty') ? 3 : 2); 94 | 95 | $this->clearEnv(); 96 | 97 | $parallel = $this->option('parallel'); 98 | 99 | $process = (new Process(array_merge( 100 | // Binary ... 101 | $this->binary(), 102 | // Arguments ... 103 | $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options) 104 | ), 105 | null, 106 | // Envs ... 107 | $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), 108 | ))->setTimeout(null); 109 | 110 | try { 111 | $process->setTty(! $this->option('without-tty')); 112 | } catch (RuntimeException $e) { 113 | // $this->output->writeln('Warning: '.$e->getMessage()); 114 | } 115 | 116 | $exitCode = 1; 117 | 118 | try { 119 | $exitCode = $process->run(function ($type, $line) { 120 | $this->output->write($line); 121 | }); 122 | } catch (ProcessSignaledException $e) { 123 | if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { 124 | throw $e; 125 | } 126 | } 127 | 128 | if ($exitCode === 0 && $this->option('coverage')) { 129 | if (! $this->usingPest() && $this->option('parallel')) { 130 | $this->newLine(); 131 | } 132 | 133 | $coverage = Coverage::report($this->output); 134 | 135 | $exitCode = (int) ($coverage < $this->option('min')); 136 | 137 | if ($exitCode === 1) { 138 | $this->output->writeln(sprintf( 139 | "\n FAIL Code coverage below expected: %s %%. Minimum: %s %%.", 140 | number_format($coverage, 1), 141 | number_format((float) $this->option('min'), 1) 142 | )); 143 | } 144 | } 145 | 146 | return $exitCode; 147 | } 148 | 149 | /** 150 | * Get the PHP binary to execute. 151 | * 152 | * @return array 153 | */ 154 | protected function binary() 155 | { 156 | if ($this->usingPest()) { 157 | $command = $this->option('parallel') ? ['vendor/pestphp/pest/bin/pest', '--parallel'] : ['vendor/pestphp/pest/bin/pest']; 158 | } else { 159 | $command = $this->option('parallel') ? ['vendor/brianium/paratest/bin/paratest'] : ['vendor/phpunit/phpunit/phpunit']; 160 | } 161 | 162 | if ('phpdbg' === PHP_SAPI) { 163 | return array_merge([PHP_BINARY, '-qrr'], $command); 164 | } 165 | 166 | return array_merge([PHP_BINARY], $command); 167 | } 168 | 169 | /** 170 | * Gets the common arguments of PHPUnit and Pest. 171 | * 172 | * @return array 173 | */ 174 | protected function commonArguments() 175 | { 176 | $arguments = []; 177 | 178 | if ($this->option('coverage')) { 179 | $arguments[] = '--coverage-php'; 180 | $arguments[] = Coverage::getPath(); 181 | } 182 | 183 | if ($this->option('ansi')) { 184 | $arguments[] = '--colors=always'; 185 | } elseif ($this->option('no-ansi')) { 186 | $arguments[] = '--colors=never'; 187 | } elseif ((new Console)->hasColorSupport()) { 188 | $arguments[] = '--colors=always'; 189 | } 190 | 191 | return $arguments; 192 | } 193 | 194 | /** 195 | * Determines if Pest is being used. 196 | * 197 | * @return bool 198 | */ 199 | protected function usingPest() 200 | { 201 | return function_exists('\Pest\\version'); 202 | } 203 | 204 | /** 205 | * Get the array of arguments for running PHPUnit. 206 | * 207 | * @param array $options 208 | * @return array 209 | */ 210 | protected function phpunitArguments($options) 211 | { 212 | $options = array_merge(['--no-output'], $options); 213 | 214 | $options = array_values(array_filter($options, function ($option) { 215 | return ! Str::startsWith($option, '--env=') 216 | && $option != '-q' 217 | && $option != '--quiet' 218 | && $option != '--coverage' 219 | && $option != '--compact' 220 | && $option != '--profile' 221 | && $option != '--ansi' 222 | && $option != '--no-ansi' 223 | && ! Str::startsWith($option, '--min'); 224 | })); 225 | 226 | return array_merge($this->commonArguments(), ['--configuration='.$this->getConfigurationFile()], $options); 227 | } 228 | 229 | /** 230 | * Get the configuration file. 231 | * 232 | * @return string 233 | */ 234 | protected function getConfigurationFile() 235 | { 236 | if (! file_exists($file = base_path('phpunit.xml'))) { 237 | $file = base_path('phpunit.xml.dist'); 238 | } 239 | 240 | return $file; 241 | } 242 | 243 | /** 244 | * Get the array of arguments for running Paratest. 245 | * 246 | * @param array $options 247 | * @return array 248 | */ 249 | protected function paratestArguments($options) 250 | { 251 | $options = array_values(array_filter($options, function ($option) { 252 | return ! Str::startsWith($option, '--env=') 253 | && $option != '--coverage' 254 | && $option != '-q' 255 | && $option != '--quiet' 256 | && $option != '--ansi' 257 | && $option != '--no-ansi' 258 | && ! Str::startsWith($option, '--min') 259 | && ! Str::startsWith($option, '-p') 260 | && ! Str::startsWith($option, '--parallel') 261 | && ! Str::startsWith($option, '--recreate-databases') 262 | && ! Str::startsWith($option, '--drop-databases') 263 | && ! Str::startsWith($option, '--without-databases'); 264 | })); 265 | 266 | $options = array_merge($this->commonArguments(), [ 267 | '--configuration='.$this->getConfigurationFile(), 268 | "--runner=\Illuminate\Testing\ParallelRunner", 269 | ], $options); 270 | 271 | $inputDefinition = new InputDefinition(); 272 | Options::setInputDefinition($inputDefinition); 273 | $input = new ArgvInput($options, $inputDefinition); 274 | 275 | /** @var non-empty-string $basePath */ 276 | $basePath = base_path(); 277 | 278 | $paraTestOptions = Options::fromConsoleInput( 279 | $input, 280 | $basePath, 281 | ); 282 | 283 | if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) { 284 | $cacheDirectory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__laravel_test_cache_directory'; 285 | $options[] = '--cache-directory'; 286 | $options[] = $cacheDirectory; 287 | } 288 | 289 | return $options; 290 | } 291 | 292 | /** 293 | * Get the array of environment variables for running PHPUnit. 294 | * 295 | * @return array 296 | */ 297 | protected function phpunitEnvironmentVariables() 298 | { 299 | $variables = [ 300 | 'COLLISION_PRINTER' => 'DefaultPrinter', 301 | ]; 302 | 303 | if ($this->option('compact')) { 304 | $variables['COLLISION_PRINTER_COMPACT'] = 'true'; 305 | } 306 | 307 | if ($this->option('profile')) { 308 | $variables['COLLISION_PRINTER_PROFILE'] = 'true'; 309 | } 310 | 311 | return $variables; 312 | } 313 | 314 | /** 315 | * Get the array of environment variables for running Paratest. 316 | * 317 | * @return array 318 | */ 319 | protected function paratestEnvironmentVariables() 320 | { 321 | return [ 322 | 'LARAVEL_PARALLEL_TESTING' => 1, 323 | 'LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES' => $this->option('recreate-databases'), 324 | 'LARAVEL_PARALLEL_TESTING_DROP_DATABASES' => $this->option('drop-databases'), 325 | 'LARAVEL_PARALLEL_TESTING_WITHOUT_DATABASES' => $this->option('without-databases'), 326 | ]; 327 | } 328 | 329 | /** 330 | * Clears any set Environment variables set by Laravel if the --env option is empty. 331 | * 332 | * @return void 333 | */ 334 | protected function clearEnv() 335 | { 336 | if (! $this->option('env')) { 337 | $vars = self::getEnvironmentVariables( 338 | $this->laravel->environmentPath(), 339 | $this->laravel->environmentFile() 340 | ); 341 | 342 | $repository = Env::getRepository(); 343 | 344 | foreach ($vars as $name) { 345 | $repository->clear($name); 346 | } 347 | } 348 | } 349 | 350 | /** 351 | * @param string $path 352 | * @param string $file 353 | * @return array 354 | */ 355 | protected static function getEnvironmentVariables($path, $file) 356 | { 357 | try { 358 | $content = StoreBuilder::createWithNoNames() 359 | ->addPath($path) 360 | ->addName($file) 361 | ->make() 362 | ->read(); 363 | } catch (InvalidPathException $e) { 364 | return []; 365 | } 366 | 367 | $vars = []; 368 | 369 | foreach ((new Parser())->parse($content) as $entry) { 370 | $vars[] = $entry->getName(); 371 | } 372 | 373 | return $vars; 374 | } 375 | 376 | /** 377 | * Check if the parallel dependencies are installed. 378 | * 379 | * @return bool 380 | */ 381 | protected function isParallelDependenciesInstalled() 382 | { 383 | return class_exists(\ParaTest\ParaTestCommand::class); 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/Subscribers/EnsurePrinterIsRegisteredSubscriber.php: -------------------------------------------------------------------------------- 1 | = 10) { 63 | /** 64 | * @internal 65 | */ 66 | final class EnsurePrinterIsRegisteredSubscriber implements StartedSubscriber 67 | { 68 | /** 69 | * If this subscriber has been registered on PHPUnit's facade. 70 | */ 71 | private static bool $registered = false; 72 | 73 | /** 74 | * Runs the subscriber. 75 | */ 76 | public function notify(Started $event): void 77 | { 78 | $printer = new ReportablePrinter(new DefaultPrinter(true)); 79 | 80 | if (isset($_SERVER['COLLISION_PRINTER_COMPACT'])) { 81 | DefaultPrinter::compact(true); 82 | } 83 | 84 | if (isset($_SERVER['COLLISION_PRINTER_PROFILE'])) { 85 | DefaultPrinter::profile(true); 86 | } 87 | 88 | $subscribers = [ 89 | // Configured 90 | new class($printer) extends Subscriber implements ConfiguredSubscriber 91 | { 92 | public function notify(Configured $event): void 93 | { 94 | $this->printer()->setDecorated( 95 | $event->configuration()->colors() 96 | ); 97 | } 98 | }, 99 | 100 | // Test 101 | new class($printer) extends Subscriber implements PrintedUnexpectedOutputSubscriber 102 | { 103 | public function notify(PrintedUnexpectedOutput $event): void 104 | { 105 | $this->printer()->testPrintedUnexpectedOutput($event); 106 | } 107 | }, 108 | 109 | // Test Runner 110 | new class($printer) extends Subscriber implements ExecutionStartedSubscriber 111 | { 112 | public function notify(ExecutionStarted $event): void 113 | { 114 | $this->printer()->testRunnerExecutionStarted($event); 115 | } 116 | }, 117 | 118 | new class($printer) extends Subscriber implements ExecutionFinishedSubscriber 119 | { 120 | public function notify(ExecutionFinished $event): void 121 | { 122 | $this->printer()->testRunnerExecutionFinished($event); 123 | } 124 | }, 125 | 126 | // Test > Hook Methods 127 | 128 | new class($printer) extends Subscriber implements BeforeFirstTestMethodErroredSubscriber 129 | { 130 | public function notify(BeforeFirstTestMethodErrored $event): void 131 | { 132 | $this->printer()->testBeforeFirstTestMethodErrored($event); 133 | } 134 | }, 135 | 136 | // Test > Lifecycle ... 137 | 138 | new class($printer) extends Subscriber implements FinishedSubscriber 139 | { 140 | public function notify(Finished $event): void 141 | { 142 | $this->printer()->testFinished($event); 143 | } 144 | }, 145 | 146 | new class($printer) extends Subscriber implements PreparationStartedSubscriber 147 | { 148 | public function notify(PreparationStarted $event): void 149 | { 150 | $this->printer()->testPreparationStarted($event); 151 | } 152 | }, 153 | 154 | // Test > Issues ... 155 | 156 | new class($printer) extends Subscriber implements ConsideredRiskySubscriber 157 | { 158 | public function notify(ConsideredRisky $event): void 159 | { 160 | $this->printer()->testConsideredRisky($event); 161 | } 162 | }, 163 | 164 | new class($printer) extends Subscriber implements DeprecationTriggeredSubscriber 165 | { 166 | public function notify(DeprecationTriggered $event): void 167 | { 168 | $this->printer()->testDeprecationTriggered($event); 169 | } 170 | }, 171 | 172 | new class($printer) extends Subscriber implements TestRunnerDeprecationTriggeredSubscriber 173 | { 174 | public function notify(TestRunnerDeprecationTriggered $event): void 175 | { 176 | $this->printer()->testRunnerDeprecationTriggered($event); 177 | } 178 | }, 179 | 180 | new class($printer) extends Subscriber implements TestRunnerWarningTriggeredSubscriber 181 | { 182 | public function notify(TestRunnerWarningTriggered $event): void 183 | { 184 | $this->printer()->testRunnerWarningTriggered($event); 185 | } 186 | }, 187 | 188 | new class($printer) extends Subscriber implements PhpDeprecationTriggeredSubscriber 189 | { 190 | public function notify(PhpDeprecationTriggered $event): void 191 | { 192 | $this->printer()->testPhpDeprecationTriggered($event); 193 | } 194 | }, 195 | 196 | new class($printer) extends Subscriber implements PhpunitDeprecationTriggeredSubscriber 197 | { 198 | public function notify(PhpunitDeprecationTriggered $event): void 199 | { 200 | $this->printer()->testPhpunitDeprecationTriggered($event); 201 | } 202 | }, 203 | 204 | new class($printer) extends Subscriber implements PhpNoticeTriggeredSubscriber 205 | { 206 | public function notify(PhpNoticeTriggered $event): void 207 | { 208 | $this->printer()->testPhpNoticeTriggered($event); 209 | } 210 | }, 211 | 212 | new class($printer) extends Subscriber implements PhpWarningTriggeredSubscriber 213 | { 214 | public function notify(PhpWarningTriggered $event): void 215 | { 216 | $this->printer()->testPhpWarningTriggered($event); 217 | } 218 | }, 219 | 220 | new class($printer) extends Subscriber implements PhpunitWarningTriggeredSubscriber 221 | { 222 | public function notify(PhpunitWarningTriggered $event): void 223 | { 224 | $this->printer()->testPhpunitWarningTriggered($event); 225 | } 226 | }, 227 | 228 | new class($printer) extends Subscriber implements PhpunitErrorTriggeredSubscriber 229 | { 230 | public function notify(PhpunitErrorTriggered $event): void 231 | { 232 | $this->printer()->testPhpunitErrorTriggered($event); 233 | } 234 | }, 235 | 236 | // Test > Outcome ... 237 | 238 | new class($printer) extends Subscriber implements ErroredSubscriber 239 | { 240 | public function notify(Errored $event): void 241 | { 242 | $this->printer()->testErrored($event); 243 | } 244 | }, 245 | new class($printer) extends Subscriber implements FailedSubscriber 246 | { 247 | public function notify(Failed $event): void 248 | { 249 | $this->printer()->testFailed($event); 250 | } 251 | }, 252 | new class($printer) extends Subscriber implements MarkedIncompleteSubscriber 253 | { 254 | public function notify(MarkedIncomplete $event): void 255 | { 256 | $this->printer()->testMarkedIncomplete($event); 257 | } 258 | }, 259 | 260 | new class($printer) extends Subscriber implements NoticeTriggeredSubscriber 261 | { 262 | public function notify(NoticeTriggered $event): void 263 | { 264 | $this->printer()->testNoticeTriggered($event); 265 | } 266 | }, 267 | 268 | new class($printer) extends Subscriber implements PassedSubscriber 269 | { 270 | public function notify(Passed $event): void 271 | { 272 | $this->printer()->testPassed($event); 273 | } 274 | }, 275 | new class($printer) extends Subscriber implements SkippedSubscriber 276 | { 277 | public function notify(Skipped $event): void 278 | { 279 | $this->printer()->testSkipped($event); 280 | } 281 | }, 282 | 283 | new class($printer) extends Subscriber implements WarningTriggeredSubscriber 284 | { 285 | public function notify(WarningTriggered $event): void 286 | { 287 | $this->printer()->testWarningTriggered($event); 288 | } 289 | }, 290 | ]; 291 | 292 | Facade::instance()->registerSubscribers(...$subscribers); 293 | } 294 | 295 | /** 296 | * Registers the subscriber on PHPUnit's facade. 297 | */ 298 | public static function register(): void 299 | { 300 | $shouldRegister = self::$registered === false 301 | && isset($_SERVER['COLLISION_PRINTER']); 302 | 303 | if ($shouldRegister) { 304 | self::$registered = true; 305 | 306 | Facade::instance()->registerSubscriber(new self()); 307 | } 308 | } 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/Printers/DefaultPrinter.php: -------------------------------------------------------------------------------- 1 | output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, $colors); 110 | 111 | ConfigureIO::of(new ArgvInput(), $this->output); 112 | 113 | self::$verbose = $this->output->isVerbose(); 114 | 115 | $this->style = new Style($this->output); 116 | 117 | $this->state = new State(); 118 | } 119 | 120 | /** 121 | * If the printer instances should be compact. 122 | */ 123 | public static function compact(?bool $value = null): bool 124 | { 125 | if (! is_null($value)) { 126 | self::$compact = $value; 127 | } 128 | 129 | return ! self::$verbose && self::$compact; 130 | } 131 | 132 | /** 133 | * Links issues with the given prefix. 134 | */ 135 | public static function linkIssuesWith(string $linkPrefix): void 136 | { 137 | self::$issuesLink = $linkPrefix; 138 | } 139 | 140 | /** 141 | * Get the issues link. 142 | */ 143 | public static function issuesLink(): ?string 144 | { 145 | return self::$issuesLink; 146 | } 147 | 148 | /** 149 | * Links PRs with the given prefix. 150 | */ 151 | public static function linkPrsWith(string $linkPrefix): void 152 | { 153 | self::$prsLink = $linkPrefix; 154 | } 155 | 156 | /** 157 | * Get the PRs link. 158 | */ 159 | public static function prsLink(): ?string 160 | { 161 | return self::$prsLink; 162 | } 163 | 164 | /** 165 | * If the printer instances should profile. 166 | */ 167 | public static function profile(?bool $value = null): bool 168 | { 169 | if (! is_null($value)) { 170 | self::$profile = $value; 171 | } 172 | 173 | return self::$profile; 174 | } 175 | 176 | /** 177 | * Defines if the output should be decorated or not. 178 | */ 179 | public function setDecorated(bool $decorated): void 180 | { 181 | $this->output->setDecorated($decorated); 182 | } 183 | 184 | /** 185 | * Listen to the runner execution started event. 186 | */ 187 | public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $printedUnexpectedOutput): void 188 | { 189 | $this->output->write($printedUnexpectedOutput->output()); 190 | } 191 | 192 | /** 193 | * Listen to the runner execution started event. 194 | */ 195 | public function testRunnerExecutionStarted(ExecutionStarted $executionStarted): void 196 | { 197 | // .. 198 | } 199 | 200 | /** 201 | * Listen to the test finished event. 202 | */ 203 | public function testFinished(Finished $event): void 204 | { 205 | $duration = (hrtime(true) - $this->testStartedAt) / 1_000_000; 206 | 207 | $test = $event->test(); 208 | 209 | if (! $test instanceof TestMethod) { 210 | throw new ShouldNotHappen(); 211 | } 212 | 213 | if (! $this->state->existsInTestCase($event->test())) { 214 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::PASS)); 215 | } 216 | 217 | $result = $this->state->setDuration($test, $duration); 218 | 219 | if (self::$profile) { 220 | $this->profileSlowTests[$event->test()->id()] = $result; 221 | 222 | // Sort the slow tests by time, and keep only 10 of them. 223 | uasort($this->profileSlowTests, static function (TestResult $a, TestResult $b) { 224 | return $b->duration <=> $a->duration; 225 | }); 226 | 227 | $this->profileSlowTests = array_slice($this->profileSlowTests, 0, 10); 228 | } 229 | } 230 | 231 | /** 232 | * Listen to the test prepared event. 233 | */ 234 | public function testPreparationStarted(PreparationStarted $event): void 235 | { 236 | $this->testStartedAt = hrtime(true); 237 | 238 | $test = $event->test(); 239 | 240 | if (! $test instanceof TestMethod) { 241 | throw new ShouldNotHappen(); 242 | } 243 | 244 | if ($this->state->testCaseHasChanged($test)) { 245 | $this->style->writeCurrentTestCaseSummary($this->state); 246 | 247 | $this->state->moveTo($test); 248 | } 249 | } 250 | 251 | /** 252 | * Listen to the test errored event. 253 | */ 254 | public function testBeforeFirstTestMethodErrored(BeforeFirstTestMethodErrored $event): void 255 | { 256 | $this->state->add(TestResult::fromBeforeFirstTestMethodErrored($event)); 257 | } 258 | 259 | /** 260 | * Listen to the test errored event. 261 | */ 262 | public function testErrored(Errored $event): void 263 | { 264 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $event->throwable())); 265 | } 266 | 267 | /** 268 | * Listen to the test failed event. 269 | */ 270 | public function testFailed(Failed $event): void 271 | { 272 | $throwable = $event->throwable(); 273 | 274 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $throwable)); 275 | } 276 | 277 | /** 278 | * Listen to the test marked incomplete event. 279 | */ 280 | public function testMarkedIncomplete(MarkedIncomplete $event): void 281 | { 282 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::INCOMPLETE, $event->throwable())); 283 | } 284 | 285 | /** 286 | * Listen to the test considered risky event. 287 | */ 288 | public function testConsideredRisky(ConsideredRisky $event): void 289 | { 290 | $throwable = ThrowableBuilder::from(new IncompleteTestError($event->message())); 291 | 292 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::RISKY, $throwable)); 293 | } 294 | 295 | /** 296 | * Listen to the test runner deprecation triggered. 297 | */ 298 | public function testRunnerDeprecationTriggered(TestRunnerDeprecationTriggered $event): void 299 | { 300 | $this->style->writeWarning($event->message()); 301 | } 302 | 303 | /** 304 | * Listen to the test runner warning triggered. 305 | */ 306 | public function testRunnerWarningTriggered(TestRunnerWarningTriggered $event): void 307 | { 308 | if (! str_starts_with($event->message(), 'No tests found in class')) { 309 | $this->style->writeWarning($event->message()); 310 | } 311 | } 312 | 313 | /** 314 | * Listen to the test runner warning triggered. 315 | */ 316 | public function testPhpDeprecationTriggered(PhpDeprecationTriggered $event): void 317 | { 318 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 319 | 320 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable)); 321 | } 322 | 323 | /** 324 | * Listen to the test runner notice triggered. 325 | */ 326 | public function testPhpNoticeTriggered(PhpNoticeTriggered $event): void 327 | { 328 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 329 | 330 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::NOTICE, $throwable)); 331 | } 332 | 333 | /** 334 | * Listen to the test php warning triggered event. 335 | */ 336 | public function testPhpWarningTriggered(PhpWarningTriggered $event): void 337 | { 338 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 339 | 340 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable)); 341 | } 342 | 343 | /** 344 | * Listen to the test runner warning triggered. 345 | */ 346 | public function testPhpunitWarningTriggered(PhpunitWarningTriggered $event): void 347 | { 348 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 349 | 350 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable)); 351 | } 352 | 353 | /** 354 | * Listen to the test deprecation triggered event. 355 | */ 356 | public function testDeprecationTriggered(DeprecationTriggered $event): void 357 | { 358 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 359 | 360 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable)); 361 | } 362 | 363 | /** 364 | * Listen to the test phpunit deprecation triggered event. 365 | */ 366 | public function testPhpunitDeprecationTriggered(PhpunitDeprecationTriggered $event): void 367 | { 368 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 369 | 370 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::DEPRECATED, $throwable)); 371 | } 372 | 373 | /** 374 | * Listen to the test phpunit error triggered event. 375 | */ 376 | public function testPhpunitErrorTriggered(PhpunitErrorTriggered $event): void 377 | { 378 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 379 | 380 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::FAIL, $throwable)); 381 | } 382 | 383 | /** 384 | * Listen to the test warning triggered event. 385 | */ 386 | public function testNoticeTriggered(NoticeTriggered $event): void 387 | { 388 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 389 | 390 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::NOTICE, $throwable)); 391 | } 392 | 393 | /** 394 | * Listen to the test warning triggered event. 395 | */ 396 | public function testWarningTriggered(WarningTriggered $event): void 397 | { 398 | $throwable = ThrowableBuilder::from(new TestOutcome($event->message())); 399 | 400 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::WARN, $throwable)); 401 | } 402 | 403 | /** 404 | * Listen to the test skipped event. 405 | */ 406 | public function testSkipped(Skipped $event): void 407 | { 408 | if ($event->message() === '__TODO__') { 409 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::TODO)); 410 | 411 | return; 412 | } 413 | 414 | $throwable = ThrowableBuilder::from(new SkippedWithMessageException($event->message())); 415 | 416 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::SKIPPED, $throwable)); 417 | } 418 | 419 | /** 420 | * Listen to the test finished event. 421 | */ 422 | public function testPassed(Passed $event): void 423 | { 424 | if (! $this->state->existsInTestCase($event->test())) { 425 | $this->state->add(TestResult::fromTestCase($event->test(), TestResult::PASS)); 426 | } 427 | } 428 | 429 | /** 430 | * Listen to the runner execution finished event. 431 | */ 432 | public function testRunnerExecutionFinished(ExecutionFinished $event): void 433 | { 434 | $result = Facade::result(); 435 | 436 | if (ResultReflection::numberOfTests(Facade::result()) === 0) { 437 | $this->output->writeln([ 438 | '', 439 | ' INFO No tests found.', 440 | '', 441 | ]); 442 | 443 | return; 444 | } 445 | 446 | $this->style->writeCurrentTestCaseSummary($this->state); 447 | 448 | if (self::$compact) { 449 | $this->output->writeln(['']); 450 | } 451 | 452 | if (class_exists(Result::class)) { 453 | $failed = Result::failed(Registry::get(), Facade::result()); 454 | } else { 455 | $failed = ! Facade::result()->wasSuccessful(); 456 | } 457 | 458 | $this->style->writeErrorsSummary($this->state); 459 | 460 | $this->style->writeRecap($this->state, $event->telemetryInfo(), $result); 461 | 462 | if (! $failed && count($this->profileSlowTests) > 0) { 463 | $this->style->writeSlowTests($this->profileSlowTests, $event->telemetryInfo()); 464 | } 465 | } 466 | 467 | /** 468 | * Reports the given throwable. 469 | */ 470 | public function report(Throwable $throwable): void 471 | { 472 | $this->style->writeError(ThrowableBuilder::from($throwable)); 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /src/Adapters/Phpunit/Style.php: -------------------------------------------------------------------------------- 1 | terminal = terminal(); 62 | $this->output = $output; 63 | 64 | $this->compactSymbolsPerLine = $this->terminal->width() - 4; 65 | } 66 | 67 | /** 68 | * Prints the content similar too:. 69 | * 70 | * ``` 71 | * WARN Your XML configuration validates against a deprecated schema... 72 | * ``` 73 | */ 74 | public function writeWarning(string $message): void 75 | { 76 | $this->output->writeln(['', ' WARN '.$message]); 77 | } 78 | 79 | /** 80 | * Prints the content similar too:. 81 | * 82 | * ``` 83 | * WARN Your XML configuration validates against a deprecated schema... 84 | * ``` 85 | */ 86 | public function writeThrowable(\Throwable $throwable): void 87 | { 88 | $this->output->writeln(['', ' ERROR '.$throwable->getMessage()]); 89 | } 90 | 91 | /** 92 | * Prints the content similar too:. 93 | * 94 | * ``` 95 | * PASS Unit\ExampleTest 96 | * ✓ basic test 97 | * ``` 98 | */ 99 | public function writeCurrentTestCaseSummary(State $state): void 100 | { 101 | if ($state->testCaseTestsCount() === 0 || is_null($state->testCaseName)) { 102 | return; 103 | } 104 | 105 | if (! $state->headerPrinted && ! DefaultPrinter::compact()) { 106 | $this->output->writeln($this->titleLineFrom( 107 | $state->getTestCaseFontColor(), 108 | $state->getTestCaseTitleColor(), 109 | $state->getTestCaseTitle(), 110 | $state->testCaseName, 111 | $state->todosCount(), 112 | )); 113 | $state->headerPrinted = true; 114 | } 115 | 116 | $state->eachTestCaseTests(function (TestResult $testResult): void { 117 | if ($testResult->description !== '') { 118 | if (DefaultPrinter::compact()) { 119 | $this->writeCompactDescriptionLine($testResult); 120 | } else { 121 | $this->writeDescriptionLine($testResult); 122 | } 123 | } 124 | }); 125 | } 126 | 127 | /** 128 | * Prints the content similar too:. 129 | * 130 | * ``` 131 | * PASS Unit\ExampleTest 132 | * ✓ basic test 133 | * ``` 134 | */ 135 | public function writeErrorsSummary(State $state): void 136 | { 137 | $configuration = Registry::get(); 138 | $failTypes = [ 139 | TestResult::FAIL, 140 | ]; 141 | 142 | if ($configuration->displayDetailsOnTestsThatTriggerNotices()) { 143 | $failTypes[] = TestResult::NOTICE; 144 | } 145 | 146 | if ($configuration->displayDetailsOnTestsThatTriggerDeprecations()) { 147 | $failTypes[] = TestResult::DEPRECATED; 148 | } 149 | 150 | if ($configuration->failOnWarning() || $configuration->displayDetailsOnTestsThatTriggerWarnings()) { 151 | $failTypes[] = TestResult::WARN; 152 | } 153 | 154 | if ($configuration->failOnRisky()) { 155 | $failTypes[] = TestResult::RISKY; 156 | } 157 | 158 | if ($configuration->failOnIncomplete() || $configuration->displayDetailsOnIncompleteTests()) { 159 | $failTypes[] = TestResult::INCOMPLETE; 160 | } 161 | 162 | if ($configuration->failOnSkipped() || $configuration->displayDetailsOnSkippedTests()) { 163 | $failTypes[] = TestResult::SKIPPED; 164 | } 165 | 166 | $failTypes = array_unique($failTypes); 167 | 168 | $errors = array_values(array_filter($state->suiteTests, fn (TestResult $testResult) => in_array( 169 | $testResult->type, 170 | $failTypes, 171 | true 172 | ))); 173 | 174 | array_map(function (TestResult $testResult): void { 175 | if (! $testResult->throwable instanceof Throwable) { 176 | throw new ShouldNotHappen(); 177 | } 178 | 179 | renderUsing($this->output); 180 | render(<<<'HTML' 181 |
182 |
183 |
184 | HTML 185 | ); 186 | 187 | $testCaseName = $testResult->testCaseName; 188 | $description = $testResult->description; 189 | 190 | /** @var class-string $throwableClassName */ 191 | $throwableClassName = $testResult->throwable->className(); 192 | 193 | $throwableClassName = ! in_array($throwableClassName, [ 194 | ExpectationFailedException::class, 195 | IncompleteTestError::class, 196 | SkippedWithMessageException::class, 197 | TestOutcome::class, 198 | ], true) ? sprintf('%s', (new ReflectionClass($throwableClassName))->getShortName()) 199 | : ''; 200 | 201 | $truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate'; 202 | 203 | renderUsing($this->output); 204 | render(sprintf(<<<'HTML' 205 |
206 | 207 | %s %s>%s 208 | 209 | 210 | %s 211 | 212 |
213 | HTML, $truncateClasses, $testResult->color === 'yellow' ? 'yellow-400' : $testResult->color, $testResult->color === 'yellow' ? 'text-black' : '', $testResult->type, $testCaseName, $description, $throwableClassName)); 214 | 215 | $this->writeError($testResult->throwable); 216 | }, $errors); 217 | } 218 | 219 | /** 220 | * Writes the final recap. 221 | */ 222 | public function writeRecap(State $state, Info $telemetry, PHPUnitTestResult $result): void 223 | { 224 | $tests = []; 225 | foreach (self::TYPES as $type) { 226 | if (($countTests = $state->countTestsInTestSuiteBy($type)) !== 0) { 227 | $color = TestResult::makeColor($type); 228 | 229 | if ($type === TestResult::WARN && $countTests < 2) { 230 | $type = 'warning'; 231 | } 232 | 233 | if ($type === TestResult::NOTICE && $countTests > 1) { 234 | $type = 'notices'; 235 | } 236 | 237 | if ($type === TestResult::TODO && $countTests > 1) { 238 | $type = 'todos'; 239 | } 240 | 241 | $tests[] = "$countTests $type"; 242 | } 243 | } 244 | 245 | $pending = ResultReflection::numberOfTests($result) - $result->numberOfTestsRun(); 246 | if ($pending > 0) { 247 | $tests[] = "\e[2m$pending pending\e[22m"; 248 | } 249 | 250 | $timeElapsed = number_format($telemetry->durationSinceStart()->asFloat(), 2, '.', ''); 251 | 252 | $this->output->writeln(['']); 253 | 254 | if (! empty($tests)) { 255 | $this->output->writeln([ 256 | sprintf( 257 | ' Tests: %s (%s assertions)', 258 | implode(', ', $tests), 259 | $result->numberOfAssertions(), 260 | ), 261 | ]); 262 | } 263 | 264 | $this->output->writeln([ 265 | sprintf( 266 | ' Duration: %ss', 267 | $timeElapsed 268 | ), 269 | ]); 270 | 271 | $this->output->writeln(''); 272 | } 273 | 274 | /** 275 | * @param array $slowTests 276 | */ 277 | public function writeSlowTests(array $slowTests, Info $telemetry): void 278 | { 279 | $this->output->writeln(' Top 10 slowest tests:'); 280 | 281 | $timeElapsed = $telemetry->durationSinceStart()->asFloat(); 282 | 283 | foreach ($slowTests as $testResult) { 284 | $seconds = number_format($testResult->duration / 1000, 2, '.', ''); 285 | 286 | $color = ($testResult->duration / 1000) > $timeElapsed * 0.25 ? 'red' : ($testResult->duration > $timeElapsed * 0.1 ? 'yellow' : 'gray'); 287 | 288 | renderUsing($this->output); 289 | render(sprintf(<<<'HTML' 290 |
291 | 292 | %s>%s 293 | 294 | 295 | %ss 296 | 297 |
298 | HTML, $testResult->testCaseName, $testResult->description, $color, $seconds)); 299 | } 300 | 301 | $timeElapsedInSlowTests = array_sum(array_map(fn (TestResult $testResult) => $testResult->duration / 1000, $slowTests)); 302 | 303 | $timeElapsedAsString = number_format($timeElapsed, 2, '.', ''); 304 | $percentageInSlowTestsAsString = number_format($timeElapsedInSlowTests * 100 / $timeElapsed, 2, '.', ''); 305 | $timeElapsedInSlowTestsAsString = number_format($timeElapsedInSlowTests, 2, '.', ''); 306 | 307 | renderUsing($this->output); 308 | render(sprintf(<<<'HTML' 309 |
310 |
311 |
312 |
313 |
314 | 315 | 316 | 317 | (%s%% of %ss) 318 | %ss 319 | 320 |
321 |
322 | HTML, $percentageInSlowTestsAsString, $timeElapsedAsString, $timeElapsedInSlowTestsAsString)); 323 | } 324 | 325 | /** 326 | * Displays the error using Collision's writer and terminates with exit code === 1. 327 | */ 328 | public function writeError(Throwable $throwable): void 329 | { 330 | $writer = (new Writer())->setOutput($this->output); 331 | 332 | $throwable = new TestException($throwable, $this->output->isVerbose()); 333 | 334 | $writer->showTitle(false); 335 | 336 | $writer->ignoreFilesIn([ 337 | '/vendor\/nunomaduro\/collision/', 338 | '/vendor\/bin\/pest/', 339 | '/bin\/pest/', 340 | '/vendor\/pestphp\/pest/', 341 | '/vendor\/pestphp\/pest-plugin-arch/', 342 | '/vendor\/phpspec\/prophecy-phpunit/', 343 | '/vendor\/phpspec\/prophecy/', 344 | '/vendor\/phpunit\/phpunit\/src/', 345 | '/vendor\/mockery\/mockery/', 346 | '/vendor\/laravel\/dusk/', 347 | '/Illuminate\/Testing/', 348 | '/Illuminate\/Foundation\/Testing/', 349 | '/Illuminate\/Foundation\/Bootstrap\/HandleExceptions/', 350 | '/vendor\/symfony\/framework-bundle\/Test/', 351 | '/vendor\/symfony\/phpunit-bridge/', 352 | '/vendor\/symfony\/dom-crawler/', 353 | '/vendor\/symfony\/browser-kit/', 354 | '/vendor\/symfony\/css-selector/', 355 | '/vendor\/bin\/.phpunit/', 356 | '/bin\/.phpunit/', 357 | '/vendor\/bin\/simple-phpunit/', 358 | '/bin\/phpunit/', 359 | '/vendor\/coduo\/php-matcher\/src\/PHPUnit/', 360 | '/vendor\/sulu\/sulu\/src\/Sulu\/Bundle\/TestBundle\/Testing/', 361 | '/vendor\/webmozart\/assert/', 362 | 363 | $this->ignorePestPipes(...), 364 | $this->ignorePestExtends(...), 365 | $this->ignorePestInterceptors(...), 366 | 367 | ]); 368 | 369 | /** @var \Throwable $throwable */ 370 | $inspector = new Inspector($throwable); 371 | 372 | $writer->write($inspector); 373 | } 374 | 375 | /** 376 | * Returns the title contents. 377 | */ 378 | private function titleLineFrom(string $fg, string $bg, string $title, string $testCaseName, int $todos): string 379 | { 380 | return sprintf( 381 | "\n %s %s%s", 382 | $fg, 383 | $bg, 384 | $title, 385 | $testCaseName, 386 | $todos > 0 ? sprintf(' - %s todo%s', $todos, $todos > 1 ? 's' : '') : '', 387 | ); 388 | } 389 | 390 | /** 391 | * Writes a description line. 392 | */ 393 | private function writeCompactDescriptionLine(TestResult $result): void 394 | { 395 | $symbolsOnCurrentLine = $this->compactProcessed % $this->compactSymbolsPerLine; 396 | 397 | if ($symbolsOnCurrentLine >= $this->terminal->width() - 4) { 398 | $symbolsOnCurrentLine = 0; 399 | } 400 | 401 | if ($symbolsOnCurrentLine === 0) { 402 | $this->output->writeln(''); 403 | $this->output->write(' '); 404 | } 405 | 406 | $this->output->write(sprintf('%s', $result->compactColor, $result->compactIcon)); 407 | 408 | $this->compactProcessed++; 409 | } 410 | 411 | /** 412 | * Writes a description line. 413 | */ 414 | private function writeDescriptionLine(TestResult $result): void 415 | { 416 | if (! empty($warning = $result->warning)) { 417 | if (! str_contains($warning, "\n")) { 418 | $warning = sprintf( 419 | ' → %s', 420 | $warning 421 | ); 422 | } else { 423 | $warningLines = explode("\n", $warning); 424 | $warning = ''; 425 | 426 | foreach ($warningLines as $w) { 427 | $warning .= sprintf( 428 | "\n ⇂ %s", 429 | trim($w) 430 | ); 431 | } 432 | } 433 | } 434 | 435 | $seconds = ''; 436 | 437 | if (($result->duration / 1000) > 0.0) { 438 | $seconds = number_format($result->duration / 1000, 2, '.', ''); 439 | $seconds = $seconds !== '0.00' ? sprintf('%ss', $seconds) : ''; 440 | } 441 | 442 | if (isset($_SERVER['REBUILD_SNAPSHOTS']) || (isset($_SERVER['COLLISION_IGNORE_DURATION']) && $_SERVER['COLLISION_IGNORE_DURATION'] === 'true')) { 443 | $seconds = ''; 444 | } 445 | 446 | $truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate'; 447 | 448 | if ($warning !== '') { 449 | $warning = sprintf('%s', $warning); 450 | 451 | if (! empty($result->warningSource)) { 452 | $warning .= ' // '.$result->warningSource; 453 | } 454 | } 455 | 456 | $description = $result->description; 457 | 458 | $issues = []; 459 | $prs = []; 460 | 461 | if (($link = DefaultPrinter::issuesLink()) && count($result->issues) > 0) { 462 | $issues = array_map(function (int $issue) use ($link): string { 463 | return sprintf('#%s', sprintf($link, $issue), $issue); 464 | }, $result->issues); 465 | } 466 | 467 | if (($link = DefaultPrinter::prsLink()) && count($result->prs) > 0) { 468 | $prs = array_map(function (int $pr) use ($link): string { 469 | return sprintf('#%s', sprintf($link, $pr), $pr); 470 | }, $result->prs); 471 | } 472 | 473 | if (count($issues) > 0 || count($prs) > 0) { 474 | $description .= ' '.implode(', ', array_merge( 475 | $issues, 476 | $prs, 477 | )); 478 | } 479 | 480 | $description = preg_replace('/`([^`]+)`/', '$1', $description); 481 | 482 | renderUsing($this->output); 483 | render(sprintf(<<<'HTML' 484 |
485 | 486 | %s%s%s 487 | %s 488 |
489 | HTML, $seconds === '' ? '' : 'flex space-x-1 justify-between', $truncateClasses, $result->color, $result->icon, $description, $warning, $seconds)); 490 | 491 | foreach ($result->notes as $note) { 492 | render(sprintf(<<<'HTML' 493 |
494 | // %s 495 |
496 | HTML, $note, 497 | )); 498 | } 499 | } 500 | 501 | /** 502 | * @param Frame $frame 503 | */ 504 | private function ignorePestPipes($frame): bool 505 | { 506 | if (class_exists(Expectation::class)) { 507 | $reflection = new ReflectionClass(Expectation::class); 508 | 509 | /** @var array> $expectationPipes */ 510 | $expectationPipes = $reflection->getStaticPropertyValue('pipes', []); 511 | 512 | foreach ($expectationPipes as $pipes) { 513 | foreach ($pipes as $pipeClosure) { 514 | if ($this->isFrameInClosure($frame, $pipeClosure)) { 515 | return true; 516 | } 517 | } 518 | } 519 | } 520 | 521 | return false; 522 | } 523 | 524 | /** 525 | * @param Frame $frame 526 | */ 527 | private function ignorePestExtends($frame): bool 528 | { 529 | if (class_exists(Expectation::class)) { 530 | $reflection = new ReflectionClass(Expectation::class); 531 | 532 | /** @var array $extends */ 533 | $extends = $reflection->getStaticPropertyValue('extends', []); 534 | 535 | foreach ($extends as $extendClosure) { 536 | if ($this->isFrameInClosure($frame, $extendClosure)) { 537 | return true; 538 | } 539 | } 540 | } 541 | 542 | return false; 543 | } 544 | 545 | /** 546 | * @param Frame $frame 547 | */ 548 | private function ignorePestInterceptors($frame): bool 549 | { 550 | if (class_exists(Expectation::class)) { 551 | $reflection = new ReflectionClass(Expectation::class); 552 | 553 | /** @var array> $expectationInterceptors */ 554 | $expectationInterceptors = $reflection->getStaticPropertyValue('interceptors', []); 555 | 556 | foreach ($expectationInterceptors as $pipes) { 557 | foreach ($pipes as $pipeClosure) { 558 | if ($this->isFrameInClosure($frame, $pipeClosure)) { 559 | return true; 560 | } 561 | } 562 | } 563 | } 564 | 565 | return false; 566 | } 567 | 568 | /** 569 | * @param Frame $frame 570 | */ 571 | private function isFrameInClosure($frame, Closure $closure): bool 572 | { 573 | $reflection = new ReflectionFunction($closure); 574 | 575 | $sanitizedPath = (string) str_replace('\\', '/', (string) $frame->getFile()); 576 | 577 | /** @phpstan-ignore-next-line */ 578 | $sanitizedClosurePath = (string) str_replace('\\', '/', $reflection->getFileName()); 579 | 580 | if ($sanitizedPath === $sanitizedClosurePath) { 581 | if ($reflection->getStartLine() <= $frame->getLine() && $frame->getLine() <= $reflection->getEndLine()) { 582 | return true; 583 | } 584 | } 585 | 586 | return false; 587 | } 588 | } 589 | --------------------------------------------------------------------------------