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