diff --git a/src/Common/Command/Run.php b/src/Common/Command/Run.php index 0cd6833..49c689f 100644 --- a/src/Common/Command/Run.php +++ b/src/Common/Command/Run.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Testo\Render\StdoutRenderer; use Testo\Render\TeamcityInterceptor; +use Testo\Render\TerminalInterceptor; #[AsCommand( name: 'run', @@ -20,7 +21,8 @@ public function __invoke( InputInterface $input, OutputInterface $output, ): int { - $this->container->bind(StdoutRenderer::class, TeamcityInterceptor::class); + // $this->container->bind(StdoutRenderer::class, TeamcityInterceptor::class); + $this->container->bind(StdoutRenderer::class, TerminalInterceptor::class); $result = $this->application->run(); diff --git a/src/Render/Terminal/Color.php b/src/Render/Terminal/Color.php new file mode 100644 index 0000000..737d83c --- /dev/null +++ b/src/Render/Terminal/Color.php @@ -0,0 +1,28 @@ + true, + self::Never => false, + self::Auto => self::detectColorSupport(), + }; + } + + /** + * Auto-detects if terminal supports colors. + */ + private static function detectColorSupport(): bool + { + // Respect NO_COLOR environment variable (https://no-color.org/) + if (isset($_SERVER['NO_COLOR']) || isset($_ENV['NO_COLOR'])) { + return false; + } + + // Check if running in CI without TTY + if (self::isCI() && !self::isTTY()) { + return false; + } + + // Check TERM environment variable + $term = $_SERVER['TERM'] ?? $_ENV['TERM'] ?? ''; + if ($term === 'dumb') { + return false; + } + + // Check if output is to a TTY + if (self::isTTY()) { + return true; + } + + // Default to no colors if can't detect + return false; + } + + /** + * Checks if running in CI environment. + */ + private static function isCI(): bool + { + $ciEnvVars = ['CI', 'CONTINUOUS_INTEGRATION', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI']; + + foreach ($ciEnvVars as $var) { + if (isset($_SERVER[$var]) || isset($_ENV[$var])) { + return true; + } + } + + return false; + } + + /** + * Checks if output is to a TTY (terminal). + */ + private static function isTTY(): bool + { + return \function_exists('posix_isatty') && @posix_isatty(\STDOUT); + } +} diff --git a/src/Render/Terminal/DotSymbol.php b/src/Render/Terminal/DotSymbol.php new file mode 100644 index 0000000..8c4b36a --- /dev/null +++ b/src/Render/Terminal/DotSymbol.php @@ -0,0 +1,17 @@ + "\n " . Style::bold("Suite: {$name}") . "\n", + OutputFormat::Compact => "\n" . Style::bold("Suite: {$name}") . "\n", + OutputFormat::Dots => "\n" . Style::bold("Suite: {$name}") . "\n", + }; + } + + /** + * Formats a test case header. + * + * @param non-empty-string $name + * @return non-empty-string + */ + public static function caseHeader(string $name, OutputFormat $format): string + { + return match ($format) { + OutputFormat::Verbose => "\n " . Style::bold("Case: {$name}") . "\n", + OutputFormat::Compact => " " . Style::bold($name) . "\n", + OutputFormat::Dots => " " . Style::bold($name) . " ", + }; + } + + /** + * Formats a single test line. + * + * @param non-empty-string $name + * @param int<0, max>|null $duration Duration in milliseconds + * @return non-empty-string + */ + public static function testLine( + string $name, + Status $status, + ?int $duration, + OutputFormat $format, + ): string { + return match ($format) { + OutputFormat::Compact, OutputFormat::Verbose => self::formatCompactTest($name, $status, $duration, $format), + OutputFormat::Dots => self::formatDotTest($status), + }; + } + + /** + * Formats case footer (dots mode). + */ + public static function caseFooter(OutputFormat $format): string + { + return $format === OutputFormat::Dots ? "\n" : ''; + } + + /** + * Formats case summary (verbose mode only). + */ + public static function caseSummary(CaseResult $result, OutputFormat $format): string + { + if ($format !== OutputFormat::Verbose) { + return ''; + } + + $parts = []; + $passed = $result->countPassedTests(); + $failed = $result->countFailedTests(); + $skipped = $result->countTests(Status::Skipped); + $risky = $result->countTests(Status::Risky); + $cancelled = $result->countTests(Status::Cancelled); + $flaky = $result->countTests(Status::Flaky); + + $passed > 0 and $parts[] = Style::success("{$passed} passed"); + $failed > 0 and $parts[] = Style::error("{$failed} failed"); + $skipped > 0 and $parts[] = Style::warning("{$skipped} skipped"); + $risky > 0 and $parts[] = Style::warning("{$risky} risky"); + $cancelled > 0 and $parts[] = Style::dim("{$cancelled} cancelled"); + $flaky > 0 and $parts[] = Style::info("{$flaky} flaky"); + + $parts === [] and $parts[] = 'no tests'; + + $summary = \implode(', ', $parts); + return " " . Style::dim("Summary: {$summary}") . "\n"; + } + + /** + * Formats suite summary. + */ + public static function suiteSummary(SuiteResult $result, OutputFormat $format): string + { + $parts = []; + $passed = $result->countPassedTests(); + $failed = $result->countFailedTests(); + $skipped = $result->countTests(Status::Skipped); + $risky = $result->countTests(Status::Risky); + $cancelled = $result->countTests(Status::Cancelled); + $flaky = $result->countTests(Status::Flaky); + $error = $result->countTests(Status::Error); + $aborted = $result->countTests(Status::Aborted); + + $passed > 0 and $parts[] = Style::success("{$passed} passed"); + $failed > 0 and $parts[] = Style::error("{$failed} failed"); + $error > 0 and $parts[] = Style::error("{$error} error"); + $skipped > 0 and $parts[] = Style::warning("{$skipped} skipped"); + $risky > 0 and $parts[] = Style::warning("{$risky} risky"); + $cancelled > 0 and $parts[] = Style::dim("{$cancelled} cancelled"); + $flaky > 0 and $parts[] = Style::info("{$flaky} flaky"); + $aborted > 0 and $parts[] = Style::error("{$aborted} aborted"); + + if ($parts === []) { + return ''; + } + + $summary = \implode(', ', $parts); + $prefix = $format === OutputFormat::Verbose ? ' ' : ''; + + return "{$prefix}" . Style::dim("Suite: {$summary}") . "\n"; + } + + /** + * Formats progress indicator. + * + * @param int<0, max> $current + * @param int<0, max> $total + * @return non-empty-string + */ + public static function progress(int $current, int $total): string + { + return "\n " . Style::dim("Progress: {$current}/{$total} tests completed") . "\n"; + } + + /** + * Formats failures section header. + * + * @return non-empty-string + */ + public static function failuresHeader(): string + { + return "\n\n " . Style::bold(Style::error('Failures:')) . "\n"; + } + + /** + * Formats a single failure detail. + * + * @param int<1, max> $index + * @param non-empty-string $testName + * @param non-empty-string $message + * @param non-empty-string $details + * @param int<0, max>|null $duration + * @return non-empty-string + */ + public static function failureDetail( + int $index, + string $testName, + string $message, + string $details, + ?int $duration, + ): string { + $durationStr = $duration !== null + ? Style::dim(" ({$duration}ms)") + : ''; + + $header = "\n " . Style::bold("{$index}) {$testName}") . $durationStr . "\n"; + $messageBlock = "\n {$message}\n"; + $detailsBlock = $details !== '' ? "\n" . self::indentText($details, ' ') . "\n" : ''; + + return $header . $messageBlock . $detailsBlock; + } + + /** + * Formats final summary section. + * + * @param int<0, max> $total + * @param int<0, max> $passed + * @param int<0, max> $failed + * @param int<0, max> $skipped + * @param int<0, max> $risky + * @param float $duration Duration in seconds + * @return non-empty-string + */ + public static function summary( + int $total, + int $passed, + int $failed, + int $skipped, + int $risky, + float $duration, + ): string { + $parts = []; + $passed > 0 and $parts[] = Style::success("{$passed} passed"); + $failed > 0 and $parts[] = Style::error("{$failed} failed"); + $skipped > 0 and $parts[] = Style::warning("{$skipped} skipped"); + $risky > 0 and $parts[] = Style::warning("{$risky} risky"); + + $testsLine = \implode(', ', $parts); + $durationFormatted = \number_format($duration, 2); + + $summary = "\n\n " . Style::bold('Summary') . "\n\n"; + $summary .= " Tests: {$testsLine} ({$total} total)\n"; + $summary .= " Duration: {$durationFormatted}s\n"; + + return $summary; + } + + /** + * Formats final status banner. + * + * @return non-empty-string + */ + public static function finalBanner(bool $success): string + { + $bg = $success ? Color::BgGreen : Color::BgRed; + $text = $success ? 'PASSED' : 'FAILED'; + + return "\n " . Style::banner($text, $bg) . "\n"; + } + + /** + * Formats test in compact/verbose mode. + * + * @param non-empty-string $name + * @param int<0, max>|null $duration + */ + private static function formatCompactTest( + string $name, + Status $status, + ?int $duration, + OutputFormat $format, + ): string { + $symbol = self::getStatusSymbol($status); + $indent = $format === OutputFormat::Verbose ? ' ' : ' '; + + $durationStr = $duration !== null + ? Style::dim(" ({$duration}ms)") + : ''; + + return "{$indent}{$symbol} {$name}{$durationStr}\n"; + } + + /** + * Formats test in dots mode. + */ + private static function formatDotTest(Status $status): string + { + $symbol = match ($status) { + Status::Passed => DotSymbol::Passed->value, + Status::Failed => Style::error(DotSymbol::Failed->value), + Status::Skipped => Style::warning(DotSymbol::Skipped->value), + Status::Error, Status::Aborted => Style::error(DotSymbol::Error->value), + Status::Risky => Style::warning(DotSymbol::Risky->value), + Status::Flaky => Style::info(DotSymbol::Passed->value), + Status::Cancelled => Style::dim(DotSymbol::Skipped->value), + }; + + return $symbol; + } + + /** + * Gets colored status symbol. + */ + private static function getStatusSymbol(Status $status): string + { + return match ($status) { + Status::Passed => Style::success(Symbol::Success->value), + Status::Failed => Style::error(Symbol::Failure->value), + Status::Skipped => Style::warning(Symbol::Skipped->value), + Status::Error, Status::Aborted => Style::error(Symbol::Error->value), + Status::Risky => Style::warning(Symbol::Risky->value), + Status::Flaky => Style::info(Symbol::Flaky->value), + Status::Cancelled => Style::dim(Symbol::Skipped->value), + }; + } + + /** + * Indents each line of text. + * + * @param non-empty-string $text + * @param non-empty-string $indent + */ + private static function indentText(string $text, string $indent): string + { + $lines = \explode("\n", $text); + $indentedLines = \array_map( + static fn(string $line): string => $line !== '' ? $indent . $line : '', + $lines, + ); + + return \implode("\n", $indentedLines); + } +} diff --git a/src/Render/Terminal/OutputFormat.php b/src/Render/Terminal/OutputFormat.php new file mode 100644 index 0000000..0a124f3 --- /dev/null +++ b/src/Render/Terminal/OutputFormat.php @@ -0,0 +1,42 @@ +value . $text . Color::Reset->value + : $text; + } + + /** + * Makes text bold. + * + * @param non-empty-string $text + */ + public static function bold(string $text): string + { + return self::$colorsEnabled + ? Color::Bold->value . $text . Color::Reset->value + : $text; + } + + /** + * Makes text dim (less visible). + * + * @param non-empty-string $text + */ + public static function dim(string $text): string + { + return self::$colorsEnabled + ? Color::Dim->value . $text . Color::Reset->value + : $text; + } + + /** + * Creates a banner with background color. + * + * @param non-empty-string $text + */ + public static function banner(string $text, Color $bg, Color $fg = Color::White): string + { + return self::$colorsEnabled + ? $fg->value . $bg->value . Color::Bold->value . " {$text} " . Color::Reset->value + : " {$text} "; + } + + /** + * Formats success text (green). + * + * @param non-empty-string $text + */ + public static function success(string $text): string + { + return self::colorize($text, Color::Green); + } + + /** + * Formats error text (red). + * + * @param non-empty-string $text + */ + public static function error(string $text): string + { + return self::colorize($text, Color::Red); + } + + /** + * Formats warning text (yellow). + * + * @param non-empty-string $text + */ + public static function warning(string $text): string + { + return self::colorize($text, Color::Yellow); + } + + /** + * Formats info text (cyan). + * + * @param non-empty-string $text + */ + public static function info(string $text): string + { + return self::colorize($text, Color::Cyan); + } +} diff --git a/src/Render/Terminal/Symbol.php b/src/Render/Terminal/Symbol.php new file mode 100644 index 0000000..0178122 --- /dev/null +++ b/src/Render/Terminal/Symbol.php @@ -0,0 +1,18 @@ + */ + private int $totalTests = 0; + + /** @var int<0, max> */ + private int $passedTests = 0; + + /** @var int<0, max> */ + private int $failedTests = 0; + + /** @var int<0, max> */ + private int $skippedTests = 0; + + /** @var int<0, max> */ + private int $riskyTests = 0; + + /** @var list|null}> */ + private array $failures = []; + + private float $startTime; + private bool $headerPrinted = false; + + public function __construct( + private readonly OutputFormat $format = OutputFormat::Compact, + ) { + $this->startTime = \microtime(true); + } + + /** + * Publishes test suite started message. + */ + public function suiteStartedFromInfo(SuiteInfo $info): void + { + $this->ensureHeader(); + echo Formatter::suiteHeader($info->name, $this->format); + } + + /** + * Handles test suite result. + */ + public function handleSuiteResult(SuiteInfo $info, SuiteResult $result): void + { + echo Formatter::suiteSummary($result, $this->format); + } + + /** + * Publishes test case started message. + */ + public function caseStartedFromInfo(CaseInfo $info): void + { + $this->ensureHeader(); + echo Formatter::caseHeader($info->name, $this->format); + } + + /** + * Handles test case result. + */ + public function handleCaseResult(CaseInfo $info, CaseResult $result): void + { + echo Formatter::caseFooter($this->format); + echo Formatter::caseSummary($result, $this->format); + } + + /** + * Publishes test started message. + */ + public function testStartedFromInfo(TestInfo $info): void + { + $this->ensureHeader(); + // No output on test start for compact/dots mode + } + + /** + * Handles test result and updates statistics. + * + * @param int<0, max>|null $duration Duration in milliseconds + */ + public function handleTestResult(TestResult $result, ?int $duration): void + { + $this->totalTests++; + + match ($result->status) { + Status::Passed, Status::Flaky => $this->handlePassedTest($result, $duration), + Status::Failed, Status::Error, Status::Aborted => $this->handleFailedTest($result, $duration), + Status::Skipped, Status::Cancelled => $this->handleSkippedTest($result, $duration), + Status::Risky => $this->handleRiskyTest($result, $duration), + }; + } + + /** + * Prints final summary with all failures and statistics. + */ + public function printSummary(): void + { + $this->printFailures(); + $this->printStatistics(); + } + + /** + * Ensures run header is printed once. + */ + private function ensureHeader(): void + { + if ($this->headerPrinted) { + return; + } + + echo Formatter::runHeader(); + $this->headerPrinted = true; + } + + /** + * Handles passed test status. + * + * @param int<0, max>|null $duration + */ + private function handlePassedTest(TestResult $result, ?int $duration): void + { + $this->passedTests++; + echo Formatter::testLine($result->info->name, $result->status, $duration, $this->format); + } + + /** + * Handles failed test status. + * + * @param int<0, max>|null $duration + */ + private function handleFailedTest(TestResult $result, ?int $duration): void + { + $this->failedTests++; + $this->failures[] = ['result' => $result, 'duration' => $duration]; + echo Formatter::testLine($result->info->name, $result->status, $duration, $this->format); + } + + /** + * Handles skipped test status. + * + * @param int<0, max>|null $duration + */ + private function handleSkippedTest(TestResult $result, ?int $duration): void + { + $this->skippedTests++; + echo Formatter::testLine($result->info->name, $result->status, $duration, $this->format); + } + + /** + * Handles risky test status. + * + * @param int<0, max>|null $duration + */ + private function handleRiskyTest(TestResult $result, ?int $duration): void + { + $this->riskyTests++; + echo Formatter::testLine($result->info->name, $result->status, $duration, $this->format); + } + + /** + * Prints all failures with details. + */ + private function printFailures(): void + { + if ($this->failures === []) { + return; + } + + echo Formatter::failuresHeader(); + + $index = 1; + foreach ($this->failures as $failure) { + $result = $failure['result']; + $duration = $failure['duration']; + $throwable = $result->failure; + + $message = $throwable?->getMessage() ?? 'Test failed'; + $details = $throwable !== null ? Helper::formatThrowable($throwable) : ''; + + echo Formatter::failureDetail( + $index, + $result->info->name, + $message, + $details, + $duration, + ); + + $index++; + } + } + + /** + * Prints final statistics. + */ + private function printStatistics(): void + { + $duration = \microtime(true) - $this->startTime; + $success = $this->failedTests === 0; + + echo Formatter::summary( + $this->totalTests, + $this->passedTests, + $this->failedTests, + $this->skippedTests, + $this->riskyTests, + $duration, + ); + + echo Formatter::finalBanner($success); + } +} diff --git a/src/Render/TerminalInterceptor.php b/src/Render/TerminalInterceptor.php new file mode 100644 index 0000000..fdd3d45 --- /dev/null +++ b/src/Render/TerminalInterceptor.php @@ -0,0 +1,85 @@ +shouldUseColors()); + } + + public function runTest(TestInfo $info, callable $next): TestResult + { + $this->logger->testStartedFromInfo($info); + + $start = \microtime(true); + /** @var TestResult $result */ + $result = $next($info); + $duration = (int) \round((\microtime(true) - $start) * 1000); + + $this->logger->handleTestResult($result, $duration); + return $result; + } + + public function runTestCase(CaseInfo $info, callable $next): CaseResult + { + $this->logger->caseStartedFromInfo($info); + + /** @var CaseResult $result */ + $result = $next($info); + + $this->logger->handleCaseResult($info, $result); + return $result; + } + + public function runTestSuite(SuiteInfo $info, callable $next): SuiteResult + { + $this->logger->suiteStartedFromInfo($info); + + /** @var SuiteResult $result */ + $result = $next($info); + + $this->logger->handleSuiteResult($info, $result); + + return $result; + } + + /** + * Prints final summary after all tests complete. + * Should be called after test execution finishes. + */ + public function printSummary(): void + { + $this->logger->printSummary(); + } +} diff --git a/src/Test/Dto/CaseInfo.php b/src/Test/Dto/CaseInfo.php index 1beeede..0996d59 100644 --- a/src/Test/Dto/CaseInfo.php +++ b/src/Test/Dto/CaseInfo.php @@ -13,6 +13,7 @@ final class CaseInfo { use CloneWith; + public readonly string $name; public function __construct( diff --git a/src/Test/Dto/CaseResult.php b/src/Test/Dto/CaseResult.php index 3223631..d7e28d8 100644 --- a/src/Test/Dto/CaseResult.php +++ b/src/Test/Dto/CaseResult.php @@ -26,6 +26,22 @@ public function getIterator(): \Traversable yield from $this->results; } + /** + * Counts tests by specific status. + * + * @return int<0, max> + */ + public function countTests(Status $status): int + { + $count = 0; + + foreach ($this->results as $testResult) { + $testResult->status === $status and $count++; + } + + return $count; + } + /** * Counts the number of failed tests. * @@ -41,4 +57,20 @@ public function countFailedTests(): int return $count; } + + /** + * Counts the number of passed tests. + * + * @return int<0, max> + */ + public function countPassedTests(): int + { + $count = 0; + + foreach ($this->results as $testResult) { + $testResult->status->isSuccessful() and $count++; + } + + return $count; + } } diff --git a/src/Test/Dto/RunResult.php b/src/Test/Dto/RunResult.php index 1ba5bb8..f0593df 100644 --- a/src/Test/Dto/RunResult.php +++ b/src/Test/Dto/RunResult.php @@ -18,7 +18,6 @@ public function __construct( * @var iterable */ public readonly iterable $results, - public readonly Status $status, ) {} diff --git a/src/Test/Dto/SuiteResult.php b/src/Test/Dto/SuiteResult.php index 658b429..1c4c579 100644 --- a/src/Test/Dto/SuiteResult.php +++ b/src/Test/Dto/SuiteResult.php @@ -29,6 +29,22 @@ public function getIterator(): \Traversable yield from $this->results; } + /** + * Counts tests by specific status across all cases in the suite. + * + * @return int<0, max> + */ + public function countTests(Status $status): int + { + $count = 0; + + foreach ($this->results as $caseResult) { + $count += $caseResult->countTests($status); + } + + return $count; + } + /** * Counts the number of failed tests across all cases in the suite. * @@ -44,4 +60,20 @@ public function countFailedTests(): int return $count; } + + /** + * Counts the number of passed tests across all cases in the suite. + * + * @return int<0, max> + */ + public function countPassedTests(): int + { + $count = 0; + + foreach ($this->results as $caseResult) { + $count += $caseResult->countPassedTests(); + } + + return $count; + } }