From 4c38a64668ad32250757f0ed5d76a3603b63c43c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 6 Mar 2026 16:07:04 +0400 Subject: [PATCH 1/8] refactor: Move Terminal and Teamcity renderers into Output namespace --- src/Bridge/Symfony/Command/Run.php | 4 ++-- src/{ => Output}/Teamcity/Teamcity/Formatter.php | 2 +- src/{ => Output}/Teamcity/Teamcity/TeamcityLogger.php | 3 ++- src/{ => Output}/Teamcity/TeamcityRenderer.php | 4 ++-- .../Symfony => Output/Terminal}/Renderer/Color.php | 2 +- .../Symfony => Output/Terminal}/Renderer/ColorMode.php | 2 +- .../Symfony => Output/Terminal}/Renderer/DotSymbol.php | 2 +- .../Terminal}/Renderer/FormattedItem.php | 2 +- .../Symfony => Output/Terminal}/Renderer/Formatter.php | 2 +- .../Symfony => Output/Terminal}/Renderer/Helper.php | 2 +- .../Symfony => Output/Terminal}/Renderer/OutputFormat.php | 2 +- .../Symfony => Output/Terminal}/Renderer/Style.php | 2 +- .../Symfony => Output/Terminal}/Renderer/Symbol.php | 2 +- .../Terminal}/Renderer/TerminalLogger.php | 2 +- .../Symfony => Output/Terminal}/TerminalRenderer.php | 8 ++++---- 15 files changed, 21 insertions(+), 20 deletions(-) rename src/{ => Output}/Teamcity/Teamcity/Formatter.php (99%) rename src/{ => Output}/Teamcity/Teamcity/TeamcityLogger.php (99%) rename src/{ => Output}/Teamcity/TeamcityRenderer.php (98%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/Color.php (91%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/ColorMode.php (98%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/DotSymbol.php (85%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/FormattedItem.php (95%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/Formatter.php (99%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/Helper.php (99%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/OutputFormat.php (94%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/Style.php (98%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/Symbol.php (88%) rename src/{Bridge/Symfony => Output/Terminal}/Renderer/TerminalLogger.php (99%) rename src/{Bridge/Symfony => Output/Terminal}/TerminalRenderer.php (97%) diff --git a/src/Bridge/Symfony/Command/Run.php b/src/Bridge/Symfony/Command/Run.php index 000698b..112df74 100644 --- a/src/Bridge/Symfony/Command/Run.php +++ b/src/Bridge/Symfony/Command/Run.php @@ -9,8 +9,8 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Testo\Bridge\Symfony\TerminalRenderer; -use Testo\Teamcity\TeamcityRenderer; +use Testo\Output\Teamcity\TeamcityRenderer; +use Testo\Output\Terminal\TerminalRenderer; /** * Executes test suites with optional filtering and custom output formatting. diff --git a/src/Teamcity/Teamcity/Formatter.php b/src/Output/Teamcity/Teamcity/Formatter.php similarity index 99% rename from src/Teamcity/Teamcity/Formatter.php rename to src/Output/Teamcity/Teamcity/Formatter.php index 4ddf7b3..3eac8b0 100644 --- a/src/Teamcity/Teamcity/Formatter.php +++ b/src/Output/Teamcity/Teamcity/Formatter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Teamcity\Teamcity; +namespace Testo\Output\Teamcity\Teamcity; /** * Formats TeamCity service messages. diff --git a/src/Teamcity/Teamcity/TeamcityLogger.php b/src/Output/Teamcity/Teamcity/TeamcityLogger.php similarity index 99% rename from src/Teamcity/Teamcity/TeamcityLogger.php rename to src/Output/Teamcity/Teamcity/TeamcityLogger.php index 9aa5e02..c0f8427 100644 --- a/src/Teamcity/Teamcity/TeamcityLogger.php +++ b/src/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Teamcity\Teamcity; +namespace Testo\Output\Teamcity\Teamcity; use Testo\Assert\State\CompositeRecord; use Testo\Assert\State\Record; @@ -16,6 +16,7 @@ use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; +use Testo\Output\Terminal\Renderer\Formatter; /** * TeamCity logger for test reporting using DTO objects. diff --git a/src/Teamcity/TeamcityRenderer.php b/src/Output/Teamcity/TeamcityRenderer.php similarity index 98% rename from src/Teamcity/TeamcityRenderer.php rename to src/Output/Teamcity/TeamcityRenderer.php index ba7aa83..911202a 100644 --- a/src/Teamcity/TeamcityRenderer.php +++ b/src/Output/Teamcity/TeamcityRenderer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Teamcity; +namespace Testo\Output\Teamcity; use Testo\Application\Config\EventListenerCollector; use Testo\Application\Config\PluginConfigurator; @@ -18,7 +18,7 @@ use Testo\Event\TestCase\TestCaseStarting; use Testo\Event\TestSuite\TestSuiteFinished; use Testo\Event\TestSuite\TestSuiteStarting; -use Testo\Teamcity\Teamcity\TeamcityLogger; +use Testo\Output\Teamcity\Teamcity\TeamcityLogger; final class TeamcityRenderer implements PluginConfigurator { diff --git a/src/Bridge/Symfony/Renderer/Color.php b/src/Output/Terminal/Renderer/Color.php similarity index 91% rename from src/Bridge/Symfony/Renderer/Color.php rename to src/Output/Terminal/Renderer/Color.php index 8693fd3..06fd7f1 100644 --- a/src/Bridge/Symfony/Renderer/Color.php +++ b/src/Output/Terminal/Renderer/Color.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * ANSI color codes for terminal styling. diff --git a/src/Bridge/Symfony/Renderer/ColorMode.php b/src/Output/Terminal/Renderer/ColorMode.php similarity index 98% rename from src/Bridge/Symfony/Renderer/ColorMode.php rename to src/Output/Terminal/Renderer/ColorMode.php index 8126fc5..77b267a 100644 --- a/src/Bridge/Symfony/Renderer/ColorMode.php +++ b/src/Output/Terminal/Renderer/ColorMode.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Color mode for terminal output. diff --git a/src/Bridge/Symfony/Renderer/DotSymbol.php b/src/Output/Terminal/Renderer/DotSymbol.php similarity index 85% rename from src/Bridge/Symfony/Renderer/DotSymbol.php rename to src/Output/Terminal/Renderer/DotSymbol.php index a754278..c3afff1 100644 --- a/src/Bridge/Symfony/Renderer/DotSymbol.php +++ b/src/Output/Terminal/Renderer/DotSymbol.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Symbols for test status representation in dots mode. diff --git a/src/Bridge/Symfony/Renderer/FormattedItem.php b/src/Output/Terminal/Renderer/FormattedItem.php similarity index 95% rename from src/Bridge/Symfony/Renderer/FormattedItem.php rename to src/Output/Terminal/Renderer/FormattedItem.php index 3f7c60a..3cff252 100644 --- a/src/Bridge/Symfony/Renderer/FormattedItem.php +++ b/src/Output/Terminal/Renderer/FormattedItem.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; use Testo\Core\Value\Status; diff --git a/src/Bridge/Symfony/Renderer/Formatter.php b/src/Output/Terminal/Renderer/Formatter.php similarity index 99% rename from src/Bridge/Symfony/Renderer/Formatter.php rename to src/Output/Terminal/Renderer/Formatter.php index 128f717..551e006 100644 --- a/src/Bridge/Symfony/Renderer/Formatter.php +++ b/src/Output/Terminal/Renderer/Formatter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; use Testo\Assert\State\CompositeRecord; use Testo\Assert\State\Record; diff --git a/src/Bridge/Symfony/Renderer/Helper.php b/src/Output/Terminal/Renderer/Helper.php similarity index 99% rename from src/Bridge/Symfony/Renderer/Helper.php rename to src/Output/Terminal/Renderer/Helper.php index 61ddd31..f1bdda3 100644 --- a/src/Bridge/Symfony/Renderer/Helper.php +++ b/src/Output/Terminal/Renderer/Helper.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Helper utilities for text-based rendering (CLI, TeamCity, logs, etc.). diff --git a/src/Bridge/Symfony/Renderer/OutputFormat.php b/src/Output/Terminal/Renderer/OutputFormat.php similarity index 94% rename from src/Bridge/Symfony/Renderer/OutputFormat.php rename to src/Output/Terminal/Renderer/OutputFormat.php index daa23bf..bcdb200 100644 --- a/src/Bridge/Symfony/Renderer/OutputFormat.php +++ b/src/Output/Terminal/Renderer/OutputFormat.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Output format for terminal renderer. diff --git a/src/Bridge/Symfony/Renderer/Style.php b/src/Output/Terminal/Renderer/Style.php similarity index 98% rename from src/Bridge/Symfony/Renderer/Style.php rename to src/Output/Terminal/Renderer/Style.php index 1d653a5..c3a5942 100644 --- a/src/Bridge/Symfony/Renderer/Style.php +++ b/src/Output/Terminal/Renderer/Style.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Terminal styling utilities with configurable color support. diff --git a/src/Bridge/Symfony/Renderer/Symbol.php b/src/Output/Terminal/Renderer/Symbol.php similarity index 88% rename from src/Bridge/Symfony/Renderer/Symbol.php rename to src/Output/Terminal/Renderer/Symbol.php index 90bf362..892bbf8 100644 --- a/src/Bridge/Symfony/Renderer/Symbol.php +++ b/src/Output/Terminal/Renderer/Symbol.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; /** * Symbols for test status representation in compact/verbose modes. diff --git a/src/Bridge/Symfony/Renderer/TerminalLogger.php b/src/Output/Terminal/Renderer/TerminalLogger.php similarity index 99% rename from src/Bridge/Symfony/Renderer/TerminalLogger.php rename to src/Output/Terminal/Renderer/TerminalLogger.php index 62420e3..c48e11f 100644 --- a/src/Bridge/Symfony/Renderer/TerminalLogger.php +++ b/src/Output/Terminal/Renderer/TerminalLogger.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony\Renderer; +namespace Testo\Output\Terminal\Renderer; use Testo\Assert\TestState; use Testo\Common\Environment; diff --git a/src/Bridge/Symfony/TerminalRenderer.php b/src/Output/Terminal/TerminalRenderer.php similarity index 97% rename from src/Bridge/Symfony/TerminalRenderer.php rename to src/Output/Terminal/TerminalRenderer.php index 6c8fa9f..13c77e1 100644 --- a/src/Bridge/Symfony/TerminalRenderer.php +++ b/src/Output/Terminal/TerminalRenderer.php @@ -2,13 +2,10 @@ declare(strict_types=1); -namespace Testo\Bridge\Symfony; +namespace Testo\Output\Terminal; use Testo\Application\Config\EventListenerCollector; use Testo\Application\Config\PluginConfigurator; -use Testo\Bridge\Symfony\Renderer\ColorMode; -use Testo\Bridge\Symfony\Renderer\Style; -use Testo\Bridge\Symfony\Renderer\TerminalLogger; use Testo\Common\Container; use Testo\Core\Context\TestInfo; use Testo\Event\Framework\SessionFinished; @@ -22,6 +19,9 @@ use Testo\Event\TestCase\TestCaseStarting; use Testo\Event\TestSuite\TestSuiteFinished; use Testo\Event\TestSuite\TestSuiteStarting; +use Testo\Output\Terminal\Renderer\ColorMode; +use Testo\Output\Terminal\Renderer\Style; +use Testo\Output\Terminal\Renderer\TerminalLogger; /** * Terminal interceptor for rendering test results with configurable output. From 5c836cc6efb68bba157dd51a2bc96e6afa20f457 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 6 Mar 2026 18:43:20 +0400 Subject: [PATCH 2/8] feat: Add CutTrace attribute and related helper --- src/Output/Rendering/CutTrace.php | 15 +++ src/Output/Rendering/StackTrace.php | 47 +++++++ .../Teamcity/Teamcity/TeamcityLogger.php | 39 +++++- testo.php | 1 + tests/Output/Stub/CutTraceStub.php | 16 +++ tests/Output/Stub/MiddlewareStub.php | 28 +++++ tests/Output/Stub/ThrowingStub.php | 21 ++++ .../Output/Unit/Rendering/StackTraceTest.php | 117 ++++++++++++++++++ tests/Output/suites.php | 24 ++++ 9 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 src/Output/Rendering/CutTrace.php create mode 100644 src/Output/Rendering/StackTrace.php create mode 100644 tests/Output/Stub/CutTraceStub.php create mode 100644 tests/Output/Stub/MiddlewareStub.php create mode 100644 tests/Output/Stub/ThrowingStub.php create mode 100644 tests/Output/Unit/Rendering/StackTraceTest.php create mode 100644 tests/Output/suites.php diff --git a/src/Output/Rendering/CutTrace.php b/src/Output/Rendering/CutTrace.php new file mode 100644 index 0000000..f7b1354 --- /dev/null +++ b/src/Output/Rendering/CutTrace.php @@ -0,0 +1,15 @@ +> $trace + * @return list> + */ + public static function cutStackTrace(array $trace): array + { + $cutIndex = null; + $limit = \min(\count($trace), self::SEARCH_DEPTH); + + for ($index = 0; $index < $limit; $index++) { + $frame = $trace[$index]; + + if (!isset($frame['class'], $frame['function'])) { + continue; + } + + try { + $method = new \ReflectionMethod($frame['class'], $frame['function']); + } catch (\ReflectionException) { + continue; + } + + if ($method->getAttributes(CutTrace::class) !== []) { + $cutIndex = $index; + $limit = \min(\count($trace), $index + 1 + self::SEARCH_DEPTH); + } + } + + return $cutIndex !== null + ? \array_slice($trace, $cutIndex) + : $trace; + } +} diff --git a/src/Output/Teamcity/Teamcity/TeamcityLogger.php b/src/Output/Teamcity/Teamcity/TeamcityLogger.php index c0f8427..80258e9 100644 --- a/src/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/src/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -16,7 +16,7 @@ use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Value\Status; -use Testo\Output\Terminal\Renderer\Formatter; +use Testo\Output\Rendering\StackTrace; /** * TeamCity logger for test reporting using DTO objects. @@ -34,12 +34,19 @@ final class TeamcityLogger */ public static function formatThrowable(\Throwable $throwable): string { - $class = $throwable::class; - $file = $throwable->getFile(); - $line = $throwable->getLine(); - $trace = $throwable->getTraceAsString(); + $parts = []; + $current = $throwable; - return "{$class}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}"; + do { + $class = $current::class; + $file = $current->getFile(); + $line = $current->getLine(); + $trace = self::formatTrace(StackTrace::cutStackTrace($current->getTrace())); + + $parts[] = "{$class}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}"; + } while ($current = $current->getPrevious()); + + return \implode("\n\nCaused by:\n", $parts); } /** @@ -242,6 +249,26 @@ public function handleSingleTestResult(TestResult $result, ?int $duration = null }; } + /** + * @param list> $trace + */ + private static function formatTrace(array $trace): string + { + $lines = []; + + foreach ($trace as $i => $frame) { + $location = isset($frame['file']) + ? "{$frame['file']}({$frame['line']})" + : '[internal function]'; + $call = isset($frame['class']) + ? "{$frame['class']}{$frame['type']}{$frame['function']}()" + : "{$frame['function']}()"; + $lines[] = "#{$i} {$location}: {$call}"; + } + + return \implode("\n", $lines); + } + private static function key(string $name): string { return "\033[36;1m{$name}:\033[0m "; diff --git a/testo.php b/testo.php index 4067cd7..e517ca3 100644 --- a/testo.php +++ b/testo.php @@ -21,5 +21,6 @@ require 'tests/Lifecycle/suites.php', require 'tests/Data/suites.php', require 'tests/Bench/suites.php', + require 'tests/Output/suites.php', ), ); diff --git a/tests/Output/Stub/CutTraceStub.php b/tests/Output/Stub/CutTraceStub.php new file mode 100644 index 0000000..f90c919 --- /dev/null +++ b/tests/Output/Stub/CutTraceStub.php @@ -0,0 +1,16 @@ +> + */ + public static function captureTrace(): array + { + return \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); + } +} diff --git a/tests/Output/Unit/Rendering/StackTraceTest.php b/tests/Output/Unit/Rendering/StackTraceTest.php new file mode 100644 index 0000000..3b1a19c --- /dev/null +++ b/tests/Output/Unit/Rendering/StackTraceTest.php @@ -0,0 +1,117 @@ +getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: CutTraceStub::run is the first frame, ThrowingStub::fail is removed + Assert::true(\count($result) < \count($trace)); + Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + $hasThrowingFrames = \array_filter( + $result, + static fn(array $f): bool => ($f['class'] ?? null) === ThrowingStub::class, + ); + Assert::same([], $hasThrowingFrames); + } + + public function testCutsFramesBelowCutTraceFromDebugBacktrace(): void + { + // Arrange + $trace = CutTraceStub::run(ThrowingStub::captureTrace(...)); + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: CutTraceStub::run is the first frame, ThrowingStub::captureTrace is removed + Assert::true(\count($result) < \count($trace)); + Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + $hasThrowingFrames = \array_filter( + $result, + static fn(array $f): bool => ($f['class'] ?? null) === ThrowingStub::class, + ); + Assert::same([], $hasThrowingFrames); + } + + public function testDoesNotCutTraceWithoutAttribute(): void + { + // Arrange + try { + MiddlewareStub::run(ThrowingStub::fail(...)); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert + Assert::same($trace, $result); + } + + public function testCutsAtOutermostCutTraceWithMultipleAttributes(): void + { + // Arrange: outer CutTrace -> closure -> inner CutTrace -> fail + try { + CutTraceStub::run(static fn() => CutTraceStub::run(ThrowingStub::fail(...))); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: outer CutTrace is the first frame, inner CutTrace and deeper are removed + Assert::true(\count($result) < \count($trace)); + Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + $cutFrames = \array_filter( + $result, + static fn(array $f): bool => ($f['class'] ?? null) === CutTraceStub::class, + ); + Assert::same(1, \count($cutFrames)); + } + + public function testDoesNotCutBeyondDepthLimit(): void + { + // Arrange: CutTrace is beyond SEARCH_DEPTH from the start + try { + CutTraceStub::run(static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...))); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + + // Act + $result = StackTrace::cutStackTrace($trace); + + // Assert: CutTrace too far from the error, nothing is cut + Assert::same($trace, $result); + } +} diff --git a/tests/Output/suites.php b/tests/Output/suites.php new file mode 100644 index 0000000..5bfbd9f --- /dev/null +++ b/tests/Output/suites.php @@ -0,0 +1,24 @@ +getFileName()), + ], + ), + ), + new SuiteConfig( + name: 'Output/Unit', + location: new FinderConfig( + include: [__DIR__ . '/Unit'], + ), + ), +]; From 70d3e2d8714de457ef464b9bb5e76f70a14071e4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 7 Mar 2026 12:36:01 +0400 Subject: [PATCH 3/8] refactor: Optimize cutting of stack trace --- src/Output/Rendering/StackTrace.php | 33 ++++++-- .../Teamcity/Teamcity/TeamcityLogger.php | 20 +++-- src/Output/Terminal/Renderer/Helper.php | 53 +------------ .../Output/Unit/Rendering/StackTraceTest.php | 77 +++++++++++++++++++ 4 files changed, 122 insertions(+), 61 deletions(-) diff --git a/src/Output/Rendering/StackTrace.php b/src/Output/Rendering/StackTrace.php index 3b4b1bb..88717b2 100644 --- a/src/Output/Rendering/StackTrace.php +++ b/src/Output/Rendering/StackTrace.php @@ -14,29 +14,50 @@ final class StackTrace /** * @param list> $trace + * @param \ReflectionFunctionAbstract|null $boundary Test function — stops CutTrace search at this frame. + * @param bool $trimAtBoundary If true, also removes all frames after the boundary. * @return list> */ - public static function cutStackTrace(array $trace): array - { + public static function cutStackTrace( + array $trace, + ?\ReflectionFunctionAbstract $boundary = null, + bool $trimAtBoundary = true, + ): array { $cutIndex = null; - $limit = \min(\count($trace), self::SEARCH_DEPTH); + $boundaryName = $boundary?->getName(); + $boundaryClass = $boundary instanceof \ReflectionMethod + ? $boundary->getDeclaringClass()->getName() + : null; + $limit = $boundary !== null + ? \count($trace) + : \min(\count($trace), self::SEARCH_DEPTH); for ($index = 0; $index < $limit; $index++) { $frame = $trace[$index]; + $class = $frame['class'] ?? null; + $function = $frame['function'] ?? null; - if (!isset($frame['class'], $frame['function'])) { + // Boundary reached — stop searching for CutTrace + if ($boundaryName !== null and $function === $boundaryName + and ($boundaryClass === null ? $class === null : $class === $boundaryClass) + ) { + $trimAtBoundary and $trace = \array_slice($trace, 0, $index + 1); + break; + } + + if ($class === null or $function === null) { continue; } try { - $method = new \ReflectionMethod($frame['class'], $frame['function']); + $method = new \ReflectionMethod($class, $function); } catch (\ReflectionException) { continue; } if ($method->getAttributes(CutTrace::class) !== []) { $cutIndex = $index; - $limit = \min(\count($trace), $index + 1 + self::SEARCH_DEPTH); + $boundary !== null or $limit = \min(\count($trace), $index + 1 + self::SEARCH_DEPTH); } } diff --git a/src/Output/Teamcity/Teamcity/TeamcityLogger.php b/src/Output/Teamcity/Teamcity/TeamcityLogger.php index 80258e9..f70da6d 100644 --- a/src/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/src/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -32,8 +32,10 @@ final class TeamcityLogger /** * Formats a throwable into a detailed string with class, message, file, line, and stack trace. */ - public static function formatThrowable(\Throwable $throwable): string - { + public static function formatThrowable( + \Throwable $throwable, + ?\ReflectionFunctionAbstract $boundary = null, + ): string { $parts = []; $current = $throwable; @@ -41,7 +43,7 @@ public static function formatThrowable(\Throwable $throwable): string $class = $current::class; $file = $current->getFile(); $line = $current->getLine(); - $trace = self::formatTrace(StackTrace::cutStackTrace($current->getTrace())); + $trace = self::formatTrace(StackTrace::cutStackTrace($current->getTrace(), $boundary)); $parts[] = "{$class}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}"; } while ($current = $current->getPrevious()); @@ -204,7 +206,9 @@ public function testFailedFromResult(TestResult $result): void $message = $failure?->getMessage() ?? 'Test failed'; $assertionHistory = $this->formatAssertionHistory($result); - $details = $failure !== null ? self::formatThrowable($failure) : ''; + $details = $failure !== null + ? self::formatThrowable($failure, $result->info->testDefinition->reflection) + : ''; if ($assertionHistory !== '') { $details = $assertionHistory . $details; @@ -332,7 +336,9 @@ private function handleFailedTest(TestResult $result, ?int $duration, ?string $o $message = $failure?->getMessage() ?? 'Test failed'; $assertionHistory = $this->formatAssertionHistory($result); - $details = $failure !== null ? self::formatThrowable($failure) : ''; + $details = $failure !== null + ? self::formatThrowable($failure, $result->info->testDefinition->reflection) + : ''; if ($assertionHistory !== '') { $details = $assertionHistory . $details; @@ -358,7 +364,9 @@ private function handleAbortedTest(TestResult $result, ?int $duration, ?string $ $name = $overrideName ?? $result->info->name; $assertionHistory = $this->formatAssertionHistory($result); - $details = $result->failure !== null ? self::formatThrowable($result->failure) : ''; + $details = $result->failure !== null + ? self::formatThrowable($result->failure, $result->info->testDefinition->reflection) + : ''; if ($assertionHistory !== '') { $details = $assertionHistory . $details; diff --git a/src/Output/Terminal/Renderer/Helper.php b/src/Output/Terminal/Renderer/Helper.php index f1bdda3..ad07a0b 100644 --- a/src/Output/Terminal/Renderer/Helper.php +++ b/src/Output/Terminal/Renderer/Helper.php @@ -4,6 +4,8 @@ namespace Testo\Output\Terminal\Renderer; +use Testo\Output\Rendering\StackTrace; + /** * Helper utilities for text-based rendering (CLI, TeamCity, logs, etc.). * @@ -58,9 +60,8 @@ private static function formatSingleThrowable( $result .= "\nFile: {$file}:{$line}"; - // Get stack trace and cut it at the test method - $trace = $throwable->getTrace(); - $cutTrace = self::cutTraceAtFunction($trace, $function); + // Get stack trace: cut internal frames (CutTrace) and trim at test boundary + $cutTrace = StackTrace::cutStackTrace($throwable->getTrace(), $function); if ($cutTrace !== []) { $result .= "\n\nStack trace:\n" . self::formatTraceArray($cutTrace); @@ -69,52 +70,6 @@ private static function formatSingleThrowable( return $result; } - /** - * Cuts the stack trace array at the specified function call. - * - * @param array}> $trace - * @return array}> - */ - private static function cutTraceAtFunction( - array $trace, - \ReflectionFunctionAbstract $function, - ): array { - $targetClass = null; - $targetFunction = $function->getName(); - - if ($function instanceof \ReflectionMethod) { - $targetClass = $function->getDeclaringClass()->getName(); - } - - $cutIndex = null; - foreach ($trace as $index => $frame) { - $frameClass = $frame['class'] ?? null; - $frameFunction = $frame['function'] ?? null; - - // Match function/method call - if ($targetClass !== null) { - // Method call - if ($frameClass === $targetClass && $frameFunction === $targetFunction) { - $cutIndex = $index; - break; - } - } else { - // Function call - if ($frameFunction === $targetFunction && $frameClass === null) { - $cutIndex = $index; - break; - } - } - } - - // Return trace up to (and including) the matched frame - if ($cutIndex !== null) { - return \array_slice($trace, 0, $cutIndex); - } - - return $trace; - } - /** * Formats trace array into a readable string. * diff --git a/tests/Output/Unit/Rendering/StackTraceTest.php b/tests/Output/Unit/Rendering/StackTraceTest.php index 3b1a19c..0b2c3ee 100644 --- a/tests/Output/Unit/Rendering/StackTraceTest.php +++ b/tests/Output/Unit/Rendering/StackTraceTest.php @@ -114,4 +114,81 @@ public function testDoesNotCutBeyondDepthLimit(): void // Assert: CutTrace too far from the error, nothing is cut Assert::same($trace, $result); } + + public function testBoundaryStopsCutTraceSearch(): void + { + // Arrange: CutTrace -> middleware -> boundary (no CutTrace between error and boundary) + try { + CutTraceStub::run(static fn() => MiddlewareStub::run(ThrowingStub::fail(...))); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act: boundary stops search before CutTrace is reached + $result = StackTrace::cutStackTrace($trace, $boundary, false); + + // Assert: trace unchanged — CutTrace is after boundary, not found + Assert::same($trace, $result); + } + + public function testBoundaryWithCutTraceBeforeBoundary(): void + { + // Arrange: error -> CutTrace -> middleware -> boundary + try { + MiddlewareStub::run( + static fn() => CutTraceStub::run(ThrowingStub::fail(...)), + ); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act + $result = StackTrace::cutStackTrace($trace, $boundary); + + // Assert: CutTrace found before boundary, internal frames cut + Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + } + + public function testBoundaryBypassesDepthLimit(): void + { + // Arrange: CutTrace is beyond SEARCH_DEPTH but before boundary + try { + MiddlewareStub::run(static fn() => CutTraceStub::run( + static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...)), + )); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act + $result = StackTrace::cutStackTrace($trace, $boundary); + + // Assert: CutTrace found despite being beyond SEARCH_DEPTH + Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same('run', $result[0]['function']); + } + + public function testTrimAtBoundaryCutsFramesAfterBoundary(): void + { + // Arrange + try { + MiddlewareStub::run(ThrowingStub::fail(...)); + } catch (\RuntimeException $e) { + $trace = $e->getTrace(); + } + $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); + + // Act + $result = StackTrace::cutStackTrace($trace, $boundary, trimAtBoundary: true); + + // Assert: trace ends at the boundary method + Assert::true(\count($result) < \count($trace)); + $lastFrame = $result[\count($result) - 1]; + Assert::same(MiddlewareStub::class, $lastFrame['class']); + Assert::same('run', $lastFrame['function']); + } } From 87a3e03b7f33af868592e302c6a82e3e24729336 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 7 Mar 2026 12:51:09 +0400 Subject: [PATCH 4/8] refactor: Add cache into StackTrace --- src/Output/Rendering/StackTrace.php | 37 ++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Output/Rendering/StackTrace.php b/src/Output/Rendering/StackTrace.php index 88717b2..8cfa4ef 100644 --- a/src/Output/Rendering/StackTrace.php +++ b/src/Output/Rendering/StackTrace.php @@ -12,6 +12,9 @@ final class StackTrace /** Max number of frames to scan for {@see CutTrace} before giving up. Resets on each match. */ private const SEARCH_DEPTH = 3; + /** @var array Cached {@see CutTrace} attribute lookup results */ + private static array $cutTraceCache = []; + /** * @param list> $trace * @param \ReflectionFunctionAbstract|null $boundary Test function — stops CutTrace search at this frame. @@ -24,6 +27,8 @@ public static function cutStackTrace( bool $trimAtBoundary = true, ): array { $cutIndex = null; + /** @var list $uncached Keys of frames checked but not yet cached */ + $uncached = []; $boundaryName = $boundary?->getName(); $boundaryClass = $boundary instanceof \ReflectionMethod ? $boundary->getDeclaringClass()->getName() @@ -49,15 +54,18 @@ public static function cutStackTrace( continue; } - try { - $method = new \ReflectionMethod($class, $function); - } catch (\ReflectionException) { - continue; - } + $key = $class . '::' . $function; - if ($method->getAttributes(CutTrace::class) !== []) { + if (self::$cutTraceCache[$key] ?? self::resolveHasCutTrace($key, $class, $function)) { $cutIndex = $index; $boundary !== null or $limit = \min(\count($trace), $index + 1 + self::SEARCH_DEPTH); + // Cache preceding uncached frames as false + foreach ($uncached as $k) { + self::$cutTraceCache[$k] = false; + } + $uncached = []; + } elseif (!isset(self::$cutTraceCache[$key])) { + $uncached[] = $key; } } @@ -65,4 +73,21 @@ public static function cutStackTrace( ? \array_slice($trace, $cutIndex) : $trace; } + + private static function resolveHasCutTrace(string $key, string $class, string $function): bool + { + try { + $method = new \ReflectionMethod($class, $function); + } catch (\ReflectionException) { + return false; + } + + if ($method->getAttributes(CutTrace::class) === []) { + return false; + } + + self::$cutTraceCache[$key] = true; + + return true; + } } From 715aeaa752db38ba47667bd842cca082494f7a5c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 7 Mar 2026 14:18:23 +0400 Subject: [PATCH 5/8] refactor: Add `#[CutTrace]` to assertion methods --- src/Assert.php | 11 +++++ src/Assert/Internal/Assertion/AssertArray.php | 4 ++ src/Assert/Internal/Assertion/AssertJson.php | 13 ++++++ .../Internal/Assertion/AssertObject.php | 3 ++ .../Internal/Assertion/AssertString.php | 41 +++++++------------ .../Assertion/Traits/IterableTrait.php | 6 +++ .../Assertion/Traits/NumericTrait.php | 5 +++ .../Teamcity/Teamcity/TeamcityLogger.php | 2 +- 8 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/Assert.php b/src/Assert.php index e44d2a7..9166d2e 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -24,6 +24,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionException; use Testo\Assert\State\Test\Fail; +use Testo\Output\Rendering\CutTrace; /** * Assertion utilities. @@ -38,6 +39,7 @@ final class Assert * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function same(mixed $expected, mixed $actual, string $message = ''): void { $actual === $expected @@ -53,6 +55,7 @@ public static function same(mixed $expected, mixed $actual, string $message = '' * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function notSame(mixed $expected, mixed $actual, string $message = ''): void { $actual !== $expected @@ -74,6 +77,7 @@ public static function notSame(mixed $expected, mixed $actual, string $message = * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function equals(mixed $expected, mixed $actual, string $message = ''): void { $actual == $expected @@ -94,6 +98,7 @@ public static function equals(mixed $expected, mixed $actual, string $message = * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function notEquals(mixed $expected, mixed $actual, string $message = ''): void { $actual != $expected @@ -114,6 +119,7 @@ public static function notEquals(mixed $expected, mixed $actual, string $message * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function true(mixed $actual, string $message = ''): void { $actual === true @@ -133,6 +139,7 @@ public static function true(mixed $actual, string $message = ''): void * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function false(mixed $actual, string $message = ''): void { $actual === false @@ -194,6 +201,7 @@ public static function contains(mixed $needle, iterable $haystack, string $messa * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function null( mixed $actual, string $message = '', @@ -215,6 +223,7 @@ public static function null( * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function blank( mixed $actual, string $message = '', @@ -238,6 +247,7 @@ public static function blank( * * @throws AssertException when the assertion fails. */ + #[CutTrace] public static function string(mixed $actual): StringType { return AssertString::validateAndCreate($actual); @@ -335,6 +345,7 @@ public static function object(mixed $actual): ObjectType * @param string $message The reason for the failure. * @throws AssertException */ + #[CutTrace] public static function fail(string $message = ''): never { $exception = new Fail($message); diff --git a/src/Assert/Internal/Assertion/AssertArray.php b/src/Assert/Internal/Assertion/AssertArray.php index a6f8197..d43e7ff 100644 --- a/src/Assert/Internal/Assertion/AssertArray.php +++ b/src/Assert/Internal/Assertion/AssertArray.php @@ -9,6 +9,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Output\Rendering\CutTrace; /** * Assertion utilities for arrays. @@ -39,6 +40,7 @@ public static function validateAndCreate(mixed $value): self return new self($value, $parent); } + #[CutTrace] #[\Override] public function hasKeys(int|string ...$keys): static { @@ -70,6 +72,7 @@ public function hasKeys(int|string ...$keys): static ); } + #[CutTrace] #[\Override] public function doesNotHaveKeys(string|int ...$keys): static { @@ -101,6 +104,7 @@ public function doesNotHaveKeys(string|int ...$keys): static ); } + #[CutTrace] #[\Override] public function isList(string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/AssertJson.php b/src/Assert/Internal/Assertion/AssertJson.php index 5c47e3b..70fcf1b 100644 --- a/src/Assert/Internal/Assertion/AssertJson.php +++ b/src/Assert/Internal/Assertion/AssertJson.php @@ -10,6 +10,7 @@ use Testo\Assert\Api\Json\JsonObject; use Testo\Assert\Api\Json\JsonStructure; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Output\Rendering\CutTrace; /** * Implementation of JSON assertions. @@ -30,6 +31,7 @@ public function __construct( * * @deprecated To be implemented */ + #[CutTrace] #[\Override] public function maxDepth(int $expected): static { @@ -41,6 +43,7 @@ public function maxDepth(int $expected): static * * @deprecated To be implemented */ + #[CutTrace] #[\Override] public function empty(): JsonCommon { @@ -56,6 +59,7 @@ public function empty(): JsonCommon * * @deprecated To be implemented */ + #[CutTrace] #[\Override] public function assertPath(string $path, callable $callback): static { @@ -69,6 +73,7 @@ public function assertPath(string $path, callable $callback): static * * @deprecated To be implemented */ + #[CutTrace] #[\Override] public function count(int $count, string $message = ''): static { @@ -78,6 +83,7 @@ public function count(int $count, string $message = ''): static /** * Asserts that the JSON string represents a valid JSON structure (object or array). */ + #[CutTrace] #[\Override] public function isStructure(): JsonStructure { @@ -87,6 +93,7 @@ public function isStructure(): JsonStructure /** * Assert that the JSON string represents a valid JSON object. */ + #[CutTrace] #[\Override] public function isObject(): JsonObject { @@ -96,6 +103,7 @@ public function isObject(): JsonObject /** * Assert that the JSON string represents a valid JSON array. */ + #[CutTrace] #[\Override] public function isArray(): JsonArray { @@ -105,6 +113,7 @@ public function isArray(): JsonArray /** * Assert that the JSON string represents a primitive value (string, number, boolean, null). */ + #[CutTrace] #[\Override] public function isPrimitive(): JsonCommon { @@ -119,6 +128,7 @@ public function isPrimitive(): JsonCommon * * @param non-empty-string $type The Psalm type to validate against */ + #[CutTrace] #[\Override] public function matchesType(string $type): static { @@ -132,6 +142,7 @@ public function matchesType(string $type): static * * @deprecated To be implemented */ + #[CutTrace] #[\Override] public function matchesSchema(string $schema): static { @@ -143,6 +154,7 @@ public function matchesSchema(string $schema): static * * @param array|string $keys The keys to check for existence. */ + #[CutTrace] #[\Override] public function hasKeys(array|string $keys, string $message = ''): JsonObject { @@ -156,6 +168,7 @@ public function hasKeys(array|string $keys, string $message = ''): JsonObject * * @deprecated To be implemented */ + #[CutTrace] #[\Override] public function decode(): mixed { diff --git a/src/Assert/Internal/Assertion/AssertObject.php b/src/Assert/Internal/Assertion/AssertObject.php index 62deee0..da9556f 100644 --- a/src/Assert/Internal/Assertion/AssertObject.php +++ b/src/Assert/Internal/Assertion/AssertObject.php @@ -8,6 +8,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Output\Rendering\CutTrace; /** * Assertion utilities for objects. @@ -38,6 +39,7 @@ public static function validateAndCreate(mixed $value): self return new self($value, $parent); } + #[CutTrace] #[\Override] public function instanceOf(string $expected, string $message = ''): static { @@ -48,6 +50,7 @@ public function instanceOf(string $expected, string $message = ''): static return $this; } + #[CutTrace] #[\Override] public function hasProperty(string $propertyName, string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/AssertString.php b/src/Assert/Internal/Assertion/AssertString.php index 6fb4304..7edb882 100644 --- a/src/Assert/Internal/Assertion/AssertString.php +++ b/src/Assert/Internal/Assertion/AssertString.php @@ -9,6 +9,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; +use Testo\Output\Rendering\CutTrace; /** * Assertion utilities for string data type. @@ -41,48 +42,36 @@ public static function validateAndCreate(mixed $value): self /** * Asserts that the string contains the given substring. * - * @param string $needle Substring to search for. + * @param non-empty-string $needle Substring to search for. * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[CutTrace] #[\Override] public function contains(string $needle, string $message = ''): static { - if (\str_contains($this->value, $needle)) { - StaticState::success($this->value, ' contains "' . $needle . '"', $message); - return $this; - } - - StaticState::fail(AssertException::compare( - $needle, - $this->value, - $message, - pattern: 'Failed asserting that string `%2$s` contains `%1$s`.', - showDiff: false, - )); + $str = 'contains "' . $needle . '"'; + \str_contains($this->value, $needle) + ? $this->parent->success($str, $message) + : throw $this->parent->fail($str, 'the substring is not found', $message); + return $this; } /** * Asserts that the string does not contain the given substring. * - * @param string $needle Substring to search for. + * @param non-empty-string $needle Substring to search for. * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[CutTrace] #[\Override] public function notContains(string $needle, string $message = ''): static { - if (!\str_contains($this->value, $needle)) { - StaticState::success($this->value, 'does not contain "' . $needle . '"', $message); - return $this; - } - - StaticState::fail(AssertException::compare( - $needle, - $this->value, - $message, - pattern: 'Failed asserting that string `%2$s` does not contain `%1$s`.', - showDiff: false, - )); + $str = 'does not contain "' . $needle . '"'; + !\str_contains($this->value, $needle) + ? $this->parent->success($str, $message) + : throw $this->parent->fail($str, 'the substring is found', $message); + return $this; } } diff --git a/src/Assert/Internal/Assertion/Traits/IterableTrait.php b/src/Assert/Internal/Assertion/Traits/IterableTrait.php index 0a41afa..d747b1b 100644 --- a/src/Assert/Internal/Assertion/Traits/IterableTrait.php +++ b/src/Assert/Internal/Assertion/Traits/IterableTrait.php @@ -6,6 +6,7 @@ use Testo\Assert\Internal\Support; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Output\Rendering\CutTrace; /** * Contains assertion methods for iterable values. @@ -15,6 +16,7 @@ */ trait IterableTrait { + #[CutTrace] #[\Override] public function notEmpty(string $message = ''): static { @@ -31,6 +33,7 @@ public function notEmpty(string $message = ''): static ); } + #[CutTrace] #[\Override] public function contains(mixed $needle, string $message = ''): static { @@ -68,6 +71,7 @@ public function every(callable $callback, string $message = ''): static return $this; } + #[CutTrace] #[\Override] public function sameSizeAs(iterable $expected, string $message = ''): static { @@ -86,6 +90,7 @@ public function sameSizeAs(iterable $expected, string $message = ''): static ); } + #[CutTrace] #[\Override] public function allOf(string $type, string $message = ''): static { @@ -111,6 +116,7 @@ public function allOf(string $type, string $message = ''): static return $this; } + #[CutTrace] #[\Override] public function hasCount(int $expected): static { diff --git a/src/Assert/Internal/Assertion/Traits/NumericTrait.php b/src/Assert/Internal/Assertion/Traits/NumericTrait.php index 1ab7fc8..ad25250 100644 --- a/src/Assert/Internal/Assertion/Traits/NumericTrait.php +++ b/src/Assert/Internal/Assertion/Traits/NumericTrait.php @@ -6,6 +6,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; +use Testo\Output\Rendering\CutTrace; /** * Contains methods for comparing numeric values @@ -21,6 +22,7 @@ trait NumericTrait * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[CutTrace] #[\Override] public function greaterThan(int|float $min, string $message = ''): static { @@ -44,6 +46,7 @@ public function greaterThan(int|float $min, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[CutTrace] #[\Override] public function greaterThanOrEqual(int|float $min, string $message = ''): static { @@ -67,6 +70,7 @@ public function greaterThanOrEqual(int|float $min, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[CutTrace] #[\Override] public function lessThan(int|float $max, string $message = ''): static { @@ -90,6 +94,7 @@ public function lessThan(int|float $max, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ + #[CutTrace] #[\Override] public function lessThanOrEqual(int|float $max, string $message = ''): static { diff --git a/src/Output/Teamcity/Teamcity/TeamcityLogger.php b/src/Output/Teamcity/Teamcity/TeamcityLogger.php index f70da6d..e88e0f5 100644 --- a/src/Output/Teamcity/Teamcity/TeamcityLogger.php +++ b/src/Output/Teamcity/Teamcity/TeamcityLogger.php @@ -43,7 +43,7 @@ public static function formatThrowable( $class = $current::class; $file = $current->getFile(); $line = $current->getLine(); - $trace = self::formatTrace(StackTrace::cutStackTrace($current->getTrace(), $boundary)); + $trace = self::formatTrace(StackTrace::cutStackTrace($current->getTrace(), $boundary, false)); $parts[] = "{$class}\nFile: {$file}:{$line}\n\nStack trace:\n{$trace}"; } while ($current = $current->getPrevious()); From b1d3065400cd6d6006bf7ab556f5b2f04338e8f8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 7 Mar 2026 15:19:36 +0400 Subject: [PATCH 6/8] chore: Add details to terminal output --- src/Output/Terminal/Renderer/Formatter.php | 4 +- .../Terminal/Renderer/TerminalLogger.php | 55 ++++++++++++++++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/Output/Terminal/Renderer/Formatter.php b/src/Output/Terminal/Renderer/Formatter.php index 551e006..2753936 100644 --- a/src/Output/Terminal/Renderer/Formatter.php +++ b/src/Output/Terminal/Renderer/Formatter.php @@ -200,16 +200,18 @@ public static function failureDetail( string $message, string $details, ?int $duration, + ?string $location = null, ): string { $durationStr = $duration !== null ? Style::dim(" ({$duration}ms)") : ''; $header = "\n " . Style::bold("{$index}) {$testName}") . $durationStr . "\n"; + $locationBlock = $location !== null ? " " . Style::dim($location) . "\n" : ''; $messageBlock = "\n {$message}\n"; $detailsBlock = $details !== '' ? "\n" . self::indentText($details, ' ') . "\n" : ''; - return $header . $messageBlock . $detailsBlock; + return $header . $locationBlock . $messageBlock . $detailsBlock; } /** diff --git a/src/Output/Terminal/Renderer/TerminalLogger.php b/src/Output/Terminal/Renderer/TerminalLogger.php index c48e11f..fead405 100644 --- a/src/Output/Terminal/Renderer/TerminalLogger.php +++ b/src/Output/Terminal/Renderer/TerminalLogger.php @@ -37,7 +37,7 @@ final class TerminalLogger /** @var int<0, max> */ private int $riskyTests = 0; - /** @var list|null}> */ + /** @var list|null, suiteName: string|null, datasetName: string|null}> */ private array $failures = []; /** @@ -52,6 +52,11 @@ final class TerminalLogger */ private ?string $currentTestName = null; + /** + * Current suite name for failure context. + */ + private ?string $currentSuiteName = null; + public function __construct( private readonly OutputFormat $format = OutputFormat::Compact, ) {} @@ -61,6 +66,7 @@ public function __construct( */ public function suiteStartedFromInfo(SuiteInfo $info): void { + $this->currentSuiteName = $info->name; echo Formatter::suiteHeader($info->name, $this->format); } @@ -211,7 +217,12 @@ private function handlePassedTest(TestResult $result, ?int $duration): void private function handleFailedTest(TestResult $result, ?int $duration): void { $this->failedTests++; - $this->failures[] = ['result' => $result, 'duration' => $duration]; + $this->failures[] = [ + 'result' => $result, + 'duration' => $duration, + 'suiteName' => $this->currentSuiteName, + 'datasetName' => $this->currentTestName, + ]; $item = new FormattedItem( name: $this->currentTestName ?? $result->info->name, @@ -341,18 +352,56 @@ function: $result->info->testDefinition->reflection, ) : ''; + $testName = self::buildFullTestName( + $result->info, + $failure['suiteName'], + $failure['datasetName'], + ); + + $reflection = $result->info->testDefinition->reflection; + $file = $reflection->getFileName(); + $line = $reflection->getStartLine(); + $location = $file !== false && $line !== false + ? "{$file}:{$line}" + : null; + echo Formatter::failureDetail( $index, - $result->info->name, + $testName, $message, $details, $duration, + $location, ); $index++; } } + /** + * Builds a fully qualified test name with suite, case, method, and dataset. + * + * Format: Suite / CaseName :: methodName > DatasetName + * + * @return non-empty-string + */ + private static function buildFullTestName( + TestInfo $info, + ?string $suiteName, + ?string $datasetName, + ): string { + $parts = []; + + $suiteName !== null and $parts[] = $suiteName; + $parts[] = $info->caseInfo->name; + + $name = \implode(' / ', $parts) . ' :: ' . $info->name; + + $datasetName !== null and $name .= ' > ' . $datasetName; + + return $name; + } + /** * Prints final statistics. */ From 6dd22d4d61fbe260751a3f401b6d02f744c92eba Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 7 Mar 2026 16:26:31 +0400 Subject: [PATCH 7/8] refactor!: Move `#[Test]` and `#[CutTrace]` attributes into Testo\Attribute namespace --- README.md | 2 +- .../TestoAttributesLocatorInterceptor.php | 2 +- src/Assert.php | 2 +- src/Assert/Internal/Assertion/AssertArray.php | 2 +- src/Assert/Internal/Assertion/AssertJson.php | 2 +- .../Internal/Assertion/AssertObject.php | 2 +- .../Internal/Assertion/AssertString.php | 2 +- .../Assertion/Traits/IterableTrait.php | 2 +- .../Assertion/Traits/NumericTrait.php | 2 +- src/Attribute/CutTrace.php | 44 +++++++++++++++++++ src/{Application => }/Attribute/Test.php | 2 +- src/Common/Environment.php | 2 +- src/Core/Definition/TestDefinition.php | 4 +- src/Output/Rendering/CutTrace.php | 15 ------- src/Output/Rendering/StackTrace.php | 2 + tests/Assert/Feature/CommonTest.php | 2 +- tests/Assert/Self/AssertArray.php | 2 +- tests/Assert/Self/AssertBlank.php | 2 +- tests/Assert/Self/AssertEquals.php | 2 +- tests/Assert/Self/AssertIterable.php | 2 +- tests/Assert/Self/AssertJson.php | 2 +- tests/Assert/Self/AssertNotEquals.php | 2 +- tests/Assert/Self/AssertNumeric.php | 2 +- tests/Assert/Self/AssertObject.php | 2 +- tests/Assert/Self/AssertString.php | 2 +- tests/Assert/Self/ExpectExceptionTest.php | 2 +- tests/Assert/Self/ExpectLeaks.php | 2 +- tests/Assert/Self/ExpectNotLeaks.php | 2 +- tests/Assert/Stub/Common.php | 2 +- tests/Data/Self/DataCross.php | 2 +- tests/Data/Self/DataProviders.php | 2 +- tests/Data/Self/DataUnion.php | 2 +- tests/Data/Self/DataZip.php | 2 +- .../TestClassWithClassLevelAttribute.php | 2 +- .../TestClassWithMethodLevelAttributes.php | 2 +- tests/Fixture/TestAttributes.php | 2 +- tests/Fixture/TestInterceptors.php | 2 +- tests/Fixture/functions.php | 2 +- tests/Lifecycle/Self/BeforeAfterAllTest.php | 2 +- tests/Lifecycle/Self/BeforeAfterTest.php | 2 +- .../Lifecycle/Self/MultipleAttributesTest.php | 2 +- tests/Lifecycle/Self/PriorityTest.php | 2 +- tests/Lifecycle/Self/StaticMethodTest.php | 2 +- tests/Output/Stub/CutTraceStub.php | 2 +- .../TestoAttributesLocatorInterceptorTest.php | 2 +- tests/Testo/Self/AsserTest.php | 2 +- .../Self/TestCase/CaseInstantiatorCache.php | 2 +- .../Self/TestCase/CaseInstantiatorStatic.php | 2 +- tests/Testo/Self/functions.php | 2 +- 49 files changed, 93 insertions(+), 62 deletions(-) create mode 100644 src/Attribute/CutTrace.php rename src/{Application => }/Attribute/Test.php (89%) delete mode 100644 src/Output/Rendering/CutTrace.php diff --git a/README.md b/README.md index 0b46ba2..f97d725 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ declare(strict_types=1); namespace Tests; -use Testo\Application\Attribute\Test; use Testo\Assert; use Testo\Assert\ExpectException; +use Testo\Attribute\Test; use Testo\Retry\RetryPolicy; final class CalculatorTest diff --git a/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php b/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php index a13df82..bf8dba1 100644 --- a/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php +++ b/src/Application/Middleware/Locator/TestoAttributesLocatorInterceptor.php @@ -4,7 +4,7 @@ namespace Testo\Application\Middleware\Locator; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; use Testo\Common\Reflection; use Testo\Core\Definition\CaseDefinitions; use Testo\Core\Value\TestType; diff --git a/src/Assert.php b/src/Assert.php index 9166d2e..6164088 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -24,7 +24,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionException; use Testo\Assert\State\Test\Fail; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Assertion utilities. diff --git a/src/Assert/Internal/Assertion/AssertArray.php b/src/Assert/Internal/Assertion/AssertArray.php index d43e7ff..4cfb355 100644 --- a/src/Assert/Internal/Assertion/AssertArray.php +++ b/src/Assert/Internal/Assertion/AssertArray.php @@ -9,7 +9,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Assertion utilities for arrays. diff --git a/src/Assert/Internal/Assertion/AssertJson.php b/src/Assert/Internal/Assertion/AssertJson.php index 70fcf1b..626db44 100644 --- a/src/Assert/Internal/Assertion/AssertJson.php +++ b/src/Assert/Internal/Assertion/AssertJson.php @@ -10,7 +10,7 @@ use Testo\Assert\Api\Json\JsonObject; use Testo\Assert\Api\Json\JsonStructure; use Testo\Assert\State\Assertion\AssertionComposite; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Implementation of JSON assertions. diff --git a/src/Assert/Internal/Assertion/AssertObject.php b/src/Assert/Internal/Assertion/AssertObject.php index da9556f..fd9da6e 100644 --- a/src/Assert/Internal/Assertion/AssertObject.php +++ b/src/Assert/Internal/Assertion/AssertObject.php @@ -8,7 +8,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Assertion utilities for objects. diff --git a/src/Assert/Internal/Assertion/AssertString.php b/src/Assert/Internal/Assertion/AssertString.php index 7edb882..ff25cda 100644 --- a/src/Assert/Internal/Assertion/AssertString.php +++ b/src/Assert/Internal/Assertion/AssertString.php @@ -9,7 +9,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Assertion utilities for string data type. diff --git a/src/Assert/Internal/Assertion/Traits/IterableTrait.php b/src/Assert/Internal/Assertion/Traits/IterableTrait.php index d747b1b..e4f368d 100644 --- a/src/Assert/Internal/Assertion/Traits/IterableTrait.php +++ b/src/Assert/Internal/Assertion/Traits/IterableTrait.php @@ -6,7 +6,7 @@ use Testo\Assert\Internal\Support; use Testo\Assert\State\Assertion\AssertionComposite; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Contains assertion methods for iterable values. diff --git a/src/Assert/Internal/Assertion/Traits/NumericTrait.php b/src/Assert/Internal/Assertion/Traits/NumericTrait.php index ad25250..ef8c3fa 100644 --- a/src/Assert/Internal/Assertion/Traits/NumericTrait.php +++ b/src/Assert/Internal/Assertion/Traits/NumericTrait.php @@ -6,7 +6,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; -use Testo\Output\Rendering\CutTrace; +use Testo\Attribute\CutTrace; /** * Contains methods for comparing numeric values diff --git a/src/Attribute/CutTrace.php b/src/Attribute/CutTrace.php new file mode 100644 index 0000000..b444886 --- /dev/null +++ b/src/Attribute/CutTrace.php @@ -0,0 +1,44 @@ +has($id), "Service '{$id}' not found in container"); + * } + * } + * ``` + * + * Also useful for domain-specific assertion methods in base test classes: + * + * ``` + * trait ApiAssertions + * { + * #[CutTrace] + * protected function assertJsonResponse(Response $response, int $status = 200): void + * { + * Assert::same($status, $response->getStatusCode()); + * Assert::string($response->getHeaderLine('Content-Type'))->contains('application/json'); + * } + * } + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class CutTrace implements Interceptable, LifecycleAttribute {} diff --git a/src/Application/Attribute/Test.php b/src/Attribute/Test.php similarity index 89% rename from src/Application/Attribute/Test.php rename to src/Attribute/Test.php index d8cc898..6b97542 100644 --- a/src/Application/Attribute/Test.php +++ b/src/Attribute/Test.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Application\Attribute; +namespace Testo\Attribute; /** * Marks a class (public methods), or a method or a function as a test. diff --git a/src/Common/Environment.php b/src/Common/Environment.php index 9c875cf..a85d02a 100644 --- a/src/Common/Environment.php +++ b/src/Common/Environment.php @@ -52,7 +52,7 @@ public static function getXDebugMode(): array { self::init(); return self::$xDebugExists && \function_exists('xdebug_info') - ? xdebug_info('mode') + ? \xdebug_info('mode') : []; } diff --git a/src/Core/Definition/TestDefinition.php b/src/Core/Definition/TestDefinition.php index 8c5ecf6..736a277 100644 --- a/src/Core/Definition/TestDefinition.php +++ b/src/Core/Definition/TestDefinition.php @@ -4,7 +4,7 @@ namespace Testo\Core\Definition; -use Testo\Application\Attribute\Test; +use Testo\Attribute\Test; final class TestDefinition { @@ -19,7 +19,7 @@ public function getDescription(): ?string return null; } - /** @var Test $testAttribute */ + /** @var \Testo\Attribute\Test $testAttribute */ $testAttribute = $attributes[0]->newInstance(); return $testAttribute->description !== '' ? $testAttribute->description : null; } diff --git a/src/Output/Rendering/CutTrace.php b/src/Output/Rendering/CutTrace.php deleted file mode 100644 index f7b1354..0000000 --- a/src/Output/Rendering/CutTrace.php +++ /dev/null @@ -1,15 +0,0 @@ - Date: Sat, 7 Mar 2026 16:51:33 +0400 Subject: [PATCH 8/8] refactor: Rename `#[CutTrace]` to `#[AssertMethod]` --- src/Assert.php | 22 +++---- src/Assert/Internal/Assertion/AssertArray.php | 8 +-- src/Assert/Internal/Assertion/AssertJson.php | 26 ++++---- .../Internal/Assertion/AssertObject.php | 6 +- .../Internal/Assertion/AssertString.php | 6 +- .../Assertion/Traits/IterableTrait.php | 12 ++-- .../Assertion/Traits/NumericTrait.php | 10 +-- src/Attribute/AssertMethod.php | 60 +++++++++++++++++ src/Attribute/CutTrace.php | 44 ------------- src/Output/Rendering/StackTrace.php | 16 ++--- src/Output/Terminal/Renderer/Helper.php | 2 +- tests/Assert/Self/AssertString.php | 5 +- ...{CutTraceStub.php => AssertMethodStub.php} | 6 +- .../Output/Unit/Rendering/StackTraceTest.php | 66 +++++++++---------- 14 files changed, 153 insertions(+), 136 deletions(-) create mode 100644 src/Attribute/AssertMethod.php delete mode 100644 src/Attribute/CutTrace.php rename tests/Output/Stub/{CutTraceStub.php => AssertMethodStub.php} (66%) diff --git a/src/Assert.php b/src/Assert.php index 6164088..c0952f1 100644 --- a/src/Assert.php +++ b/src/Assert.php @@ -24,7 +24,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionException; use Testo\Assert\State\Test\Fail; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Assertion utilities. @@ -39,7 +39,7 @@ final class Assert * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function same(mixed $expected, mixed $actual, string $message = ''): void { $actual === $expected @@ -55,7 +55,7 @@ public static function same(mixed $expected, mixed $actual, string $message = '' * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function notSame(mixed $expected, mixed $actual, string $message = ''): void { $actual !== $expected @@ -77,7 +77,7 @@ public static function notSame(mixed $expected, mixed $actual, string $message = * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function equals(mixed $expected, mixed $actual, string $message = ''): void { $actual == $expected @@ -98,7 +98,7 @@ public static function equals(mixed $expected, mixed $actual, string $message = * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function notEquals(mixed $expected, mixed $actual, string $message = ''): void { $actual != $expected @@ -119,7 +119,7 @@ public static function notEquals(mixed $expected, mixed $actual, string $message * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function true(mixed $actual, string $message = ''): void { $actual === true @@ -139,7 +139,7 @@ public static function true(mixed $actual, string $message = ''): void * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function false(mixed $actual, string $message = ''): void { $actual === false @@ -201,7 +201,7 @@ public static function contains(mixed $needle, iterable $haystack, string $messa * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function null( mixed $actual, string $message = '', @@ -223,7 +223,7 @@ public static function null( * @param string $message Short description about what exactly is being asserted. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function blank( mixed $actual, string $message = '', @@ -247,7 +247,7 @@ public static function blank( * * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] public static function string(mixed $actual): StringType { return AssertString::validateAndCreate($actual); @@ -345,7 +345,7 @@ public static function object(mixed $actual): ObjectType * @param string $message The reason for the failure. * @throws AssertException */ - #[CutTrace] + #[AssertMethod] public static function fail(string $message = ''): never { $exception = new Fail($message); diff --git a/src/Assert/Internal/Assertion/AssertArray.php b/src/Assert/Internal/Assertion/AssertArray.php index 4cfb355..2197d5d 100644 --- a/src/Assert/Internal/Assertion/AssertArray.php +++ b/src/Assert/Internal/Assertion/AssertArray.php @@ -9,7 +9,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Assertion utilities for arrays. @@ -40,7 +40,7 @@ public static function validateAndCreate(mixed $value): self return new self($value, $parent); } - #[CutTrace] + #[AssertMethod] #[\Override] public function hasKeys(int|string ...$keys): static { @@ -72,7 +72,7 @@ public function hasKeys(int|string ...$keys): static ); } - #[CutTrace] + #[AssertMethod] #[\Override] public function doesNotHaveKeys(string|int ...$keys): static { @@ -104,7 +104,7 @@ public function doesNotHaveKeys(string|int ...$keys): static ); } - #[CutTrace] + #[AssertMethod] #[\Override] public function isList(string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/AssertJson.php b/src/Assert/Internal/Assertion/AssertJson.php index 626db44..73fb598 100644 --- a/src/Assert/Internal/Assertion/AssertJson.php +++ b/src/Assert/Internal/Assertion/AssertJson.php @@ -10,7 +10,7 @@ use Testo\Assert\Api\Json\JsonObject; use Testo\Assert\Api\Json\JsonStructure; use Testo\Assert\State\Assertion\AssertionComposite; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Implementation of JSON assertions. @@ -31,7 +31,7 @@ public function __construct( * * @deprecated To be implemented */ - #[CutTrace] + #[AssertMethod] #[\Override] public function maxDepth(int $expected): static { @@ -43,7 +43,7 @@ public function maxDepth(int $expected): static * * @deprecated To be implemented */ - #[CutTrace] + #[AssertMethod] #[\Override] public function empty(): JsonCommon { @@ -59,7 +59,7 @@ public function empty(): JsonCommon * * @deprecated To be implemented */ - #[CutTrace] + #[AssertMethod] #[\Override] public function assertPath(string $path, callable $callback): static { @@ -73,7 +73,7 @@ public function assertPath(string $path, callable $callback): static * * @deprecated To be implemented */ - #[CutTrace] + #[AssertMethod] #[\Override] public function count(int $count, string $message = ''): static { @@ -83,7 +83,7 @@ public function count(int $count, string $message = ''): static /** * Asserts that the JSON string represents a valid JSON structure (object or array). */ - #[CutTrace] + #[AssertMethod] #[\Override] public function isStructure(): JsonStructure { @@ -93,7 +93,7 @@ public function isStructure(): JsonStructure /** * Assert that the JSON string represents a valid JSON object. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function isObject(): JsonObject { @@ -103,7 +103,7 @@ public function isObject(): JsonObject /** * Assert that the JSON string represents a valid JSON array. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function isArray(): JsonArray { @@ -113,7 +113,7 @@ public function isArray(): JsonArray /** * Assert that the JSON string represents a primitive value (string, number, boolean, null). */ - #[CutTrace] + #[AssertMethod] #[\Override] public function isPrimitive(): JsonCommon { @@ -128,7 +128,7 @@ public function isPrimitive(): JsonCommon * * @param non-empty-string $type The Psalm type to validate against */ - #[CutTrace] + #[AssertMethod] #[\Override] public function matchesType(string $type): static { @@ -142,7 +142,7 @@ public function matchesType(string $type): static * * @deprecated To be implemented */ - #[CutTrace] + #[AssertMethod] #[\Override] public function matchesSchema(string $schema): static { @@ -154,7 +154,7 @@ public function matchesSchema(string $schema): static * * @param array|string $keys The keys to check for existence. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function hasKeys(array|string $keys, string $message = ''): JsonObject { @@ -168,7 +168,7 @@ public function hasKeys(array|string $keys, string $message = ''): JsonObject * * @deprecated To be implemented */ - #[CutTrace] + #[AssertMethod] #[\Override] public function decode(): mixed { diff --git a/src/Assert/Internal/Assertion/AssertObject.php b/src/Assert/Internal/Assertion/AssertObject.php index fd9da6e..012c276 100644 --- a/src/Assert/Internal/Assertion/AssertObject.php +++ b/src/Assert/Internal/Assertion/AssertObject.php @@ -8,7 +8,7 @@ use Testo\Assert\Internal\StaticState; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Assertion utilities for objects. @@ -39,7 +39,7 @@ public static function validateAndCreate(mixed $value): self return new self($value, $parent); } - #[CutTrace] + #[AssertMethod] #[\Override] public function instanceOf(string $expected, string $message = ''): static { @@ -50,7 +50,7 @@ public function instanceOf(string $expected, string $message = ''): static return $this; } - #[CutTrace] + #[AssertMethod] #[\Override] public function hasProperty(string $propertyName, string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/AssertString.php b/src/Assert/Internal/Assertion/AssertString.php index ff25cda..f741e6c 100644 --- a/src/Assert/Internal/Assertion/AssertString.php +++ b/src/Assert/Internal/Assertion/AssertString.php @@ -9,7 +9,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; use Testo\Assert\State\Assertion\AssertionException; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Assertion utilities for string data type. @@ -46,7 +46,7 @@ public static function validateAndCreate(mixed $value): self * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function contains(string $needle, string $message = ''): static { @@ -64,7 +64,7 @@ public function contains(string $needle, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function notContains(string $needle, string $message = ''): static { diff --git a/src/Assert/Internal/Assertion/Traits/IterableTrait.php b/src/Assert/Internal/Assertion/Traits/IterableTrait.php index e4f368d..55ff96b 100644 --- a/src/Assert/Internal/Assertion/Traits/IterableTrait.php +++ b/src/Assert/Internal/Assertion/Traits/IterableTrait.php @@ -6,7 +6,7 @@ use Testo\Assert\Internal\Support; use Testo\Assert\State\Assertion\AssertionComposite; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Contains assertion methods for iterable values. @@ -16,7 +16,7 @@ */ trait IterableTrait { - #[CutTrace] + #[AssertMethod] #[\Override] public function notEmpty(string $message = ''): static { @@ -33,7 +33,7 @@ public function notEmpty(string $message = ''): static ); } - #[CutTrace] + #[AssertMethod] #[\Override] public function contains(mixed $needle, string $message = ''): static { @@ -71,7 +71,7 @@ public function every(callable $callback, string $message = ''): static return $this; } - #[CutTrace] + #[AssertMethod] #[\Override] public function sameSizeAs(iterable $expected, string $message = ''): static { @@ -90,7 +90,7 @@ public function sameSizeAs(iterable $expected, string $message = ''): static ); } - #[CutTrace] + #[AssertMethod] #[\Override] public function allOf(string $type, string $message = ''): static { @@ -116,7 +116,7 @@ public function allOf(string $type, string $message = ''): static return $this; } - #[CutTrace] + #[AssertMethod] #[\Override] public function hasCount(int $expected): static { diff --git a/src/Assert/Internal/Assertion/Traits/NumericTrait.php b/src/Assert/Internal/Assertion/Traits/NumericTrait.php index ef8c3fa..f7eaed2 100644 --- a/src/Assert/Internal/Assertion/Traits/NumericTrait.php +++ b/src/Assert/Internal/Assertion/Traits/NumericTrait.php @@ -6,7 +6,7 @@ use Testo\Assert\State\AssertException; use Testo\Assert\State\Assertion\AssertionComposite; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * Contains methods for comparing numeric values @@ -22,7 +22,7 @@ trait NumericTrait * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function greaterThan(int|float $min, string $message = ''): static { @@ -46,7 +46,7 @@ public function greaterThan(int|float $min, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function greaterThanOrEqual(int|float $min, string $message = ''): static { @@ -70,7 +70,7 @@ public function greaterThanOrEqual(int|float $min, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function lessThan(int|float $max, string $message = ''): static { @@ -94,7 +94,7 @@ public function lessThan(int|float $max, string $message = ''): static * @param string $message Optional message for the assertion. * @throws AssertException when the assertion fails. */ - #[CutTrace] + #[AssertMethod] #[\Override] public function lessThanOrEqual(int|float $max, string $message = ''): static { diff --git a/src/Attribute/AssertMethod.php b/src/Attribute/AssertMethod.php new file mode 100644 index 0000000..ecca71a --- /dev/null +++ b/src/Attribute/AssertMethod.php @@ -0,0 +1,60 @@ +has($id), "Service '{$id}' not found in container"); + * } + * } + * ``` + * + * Also useful for domain-specific assertions in traits: + * + * ``` + * trait ApiAssertions + * { + * #[AssertMethod] + * protected function assertJsonResponse(Response $response, int $status = 200): void + * { + * Assert::same($status, $response->getStatusCode()); + * Assert::string($response->getHeaderLine('Content-Type'))->contains('application/json'); + * } + * } + * ``` + * + * Without `#[AssertMethod]`: + * + * ``` + * #0 src/Assert/Internal/StaticState.php(42): Assert::same() + * #1 src/Assert.php(35): StaticState::fail() + * #2 app/Testing/ApiAssertions.php(12): Assert::same() + * #3 tests/Api/OrderTest.php(28): OrderTest->assertJsonResponse() + * ``` + * + * With `#[AssertMethod]`: + * + * ``` + * #0 app/Testing/ApiAssertions.php(12): Assert::same() + * #1 tests/Api/OrderTest.php(28): OrderTest->assertJsonResponse() + * ``` + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class AssertMethod implements Interceptable, LifecycleAttribute {} diff --git a/src/Attribute/CutTrace.php b/src/Attribute/CutTrace.php deleted file mode 100644 index b444886..0000000 --- a/src/Attribute/CutTrace.php +++ /dev/null @@ -1,44 +0,0 @@ -has($id), "Service '{$id}' not found in container"); - * } - * } - * ``` - * - * Also useful for domain-specific assertion methods in base test classes: - * - * ``` - * trait ApiAssertions - * { - * #[CutTrace] - * protected function assertJsonResponse(Response $response, int $status = 200): void - * { - * Assert::same($status, $response->getStatusCode()); - * Assert::string($response->getHeaderLine('Content-Type'))->contains('application/json'); - * } - * } - */ -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] -final class CutTrace implements Interceptable, LifecycleAttribute {} diff --git a/src/Output/Rendering/StackTrace.php b/src/Output/Rendering/StackTrace.php index 8da5b2d..92d4bbb 100644 --- a/src/Output/Rendering/StackTrace.php +++ b/src/Output/Rendering/StackTrace.php @@ -4,22 +4,22 @@ namespace Testo\Output\Rendering; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; /** * @internal */ final class StackTrace { - /** Max number of frames to scan for {@see CutTrace} before giving up. Resets on each match. */ + /** Max number of frames to scan for {@see AssertMethod} before giving up. Resets on each match. */ private const SEARCH_DEPTH = 3; - /** @var array Cached {@see CutTrace} attribute lookup results */ + /** @var array Cached {@see AssertMethod} attribute lookup results */ private static array $cutTraceCache = []; /** * @param list> $trace - * @param \ReflectionFunctionAbstract|null $boundary Test function — stops CutTrace search at this frame. + * @param \ReflectionFunctionAbstract|null $boundary Test function — stops AssertMethod search at this frame. * @param bool $trimAtBoundary If true, also removes all frames after the boundary. * @return list> */ @@ -44,7 +44,7 @@ public static function cutStackTrace( $class = $frame['class'] ?? null; $function = $frame['function'] ?? null; - // Boundary reached — stop searching for CutTrace + // Boundary reached — stop searching for AssertMethod if ($boundaryName !== null and $function === $boundaryName and ($boundaryClass === null ? $class === null : $class === $boundaryClass) ) { @@ -58,7 +58,7 @@ public static function cutStackTrace( $key = $class . '::' . $function; - if (self::$cutTraceCache[$key] ?? self::resolveHasCutTrace($key, $class, $function)) { + if (self::$cutTraceCache[$key] ?? self::resolveHasAssertMethod($key, $class, $function)) { $cutIndex = $index; $boundary !== null or $limit = \min(\count($trace), $index + 1 + self::SEARCH_DEPTH); // Cache preceding uncached frames as false @@ -76,7 +76,7 @@ public static function cutStackTrace( : $trace; } - private static function resolveHasCutTrace(string $key, string $class, string $function): bool + private static function resolveHasAssertMethod(string $key, string $class, string $function): bool { try { $method = new \ReflectionMethod($class, $function); @@ -84,7 +84,7 @@ private static function resolveHasCutTrace(string $key, string $class, string $f return false; } - if ($method->getAttributes(CutTrace::class) === []) { + if ($method->getAttributes(AssertMethod::class) === []) { return false; } diff --git a/src/Output/Terminal/Renderer/Helper.php b/src/Output/Terminal/Renderer/Helper.php index ad07a0b..3f48aae 100644 --- a/src/Output/Terminal/Renderer/Helper.php +++ b/src/Output/Terminal/Renderer/Helper.php @@ -60,7 +60,7 @@ private static function formatSingleThrowable( $result .= "\nFile: {$file}:{$line}"; - // Get stack trace: cut internal frames (CutTrace) and trim at test boundary + // Get stack trace: cut internal frames (AssertMethod) and trim at test boundary $cutTrace = StackTrace::cutStackTrace($throwable->getTrace(), $function); if ($cutTrace !== []) { diff --git a/tests/Assert/Self/AssertString.php b/tests/Assert/Self/AssertString.php index 911140c..176ae27 100644 --- a/tests/Assert/Self/AssertString.php +++ b/tests/Assert/Self/AssertString.php @@ -6,6 +6,7 @@ use Testo\Assert; use Testo\Assert\State\AssertException; +use Testo\Assert\State\Assertion\AssertionException; use Testo\Attribute\Test; use Testo\Expect; @@ -41,14 +42,14 @@ public function chainLogShowcase(): void #[Test] public function checkWrongDataType(): void { - Expect::exception(Assert\State\Assertion\AssertionException::class); + Expect::exception(AssertionException::class); Assert::string([666]); } #[Test] public function checkStringDoesNotContain(): void { - Expect::exception(AssertException::class); + Expect::exception(AssertionException::class); Assert::string("What makes PHP the best programming language?")->contains("PHP is dying"); } } diff --git a/tests/Output/Stub/CutTraceStub.php b/tests/Output/Stub/AssertMethodStub.php similarity index 66% rename from tests/Output/Stub/CutTraceStub.php rename to tests/Output/Stub/AssertMethodStub.php index c52d9ad..bde287c 100644 --- a/tests/Output/Stub/CutTraceStub.php +++ b/tests/Output/Stub/AssertMethodStub.php @@ -4,11 +4,11 @@ namespace Tests\Output\Stub; -use Testo\Attribute\CutTrace; +use Testo\Attribute\AssertMethod; -final class CutTraceStub +final class AssertMethodStub { - #[CutTrace] + #[AssertMethod] public static function run(callable $callback): mixed { return $callback(); diff --git a/tests/Output/Unit/Rendering/StackTraceTest.php b/tests/Output/Unit/Rendering/StackTraceTest.php index 0b2c3ee..85fc25a 100644 --- a/tests/Output/Unit/Rendering/StackTraceTest.php +++ b/tests/Output/Unit/Rendering/StackTraceTest.php @@ -6,7 +6,7 @@ use Testo\Assert; use Testo\Output\Rendering\StackTrace; -use Tests\Output\Stub\CutTraceStub; +use Tests\Output\Stub\AssertMethodStub; use Tests\Output\Stub\MiddlewareStub; use Tests\Output\Stub\ThrowingStub; @@ -18,11 +18,11 @@ public function testEmptyTraceReturnsEmpty(): void Assert::same([], StackTrace::cutStackTrace([])); } - public function testCutsFramesBelowCutTraceFromExceptionTrace(): void + public function testCutsFramesBelowAssertMethodFromExceptionTrace(): void { // Arrange try { - CutTraceStub::run(ThrowingStub::fail(...)); + AssertMethodStub::run(ThrowingStub::fail(...)); } catch (\RuntimeException $e) { $trace = $e->getTrace(); } @@ -30,9 +30,9 @@ public function testCutsFramesBelowCutTraceFromExceptionTrace(): void // Act $result = StackTrace::cutStackTrace($trace); - // Assert: CutTraceStub::run is the first frame, ThrowingStub::fail is removed + // Assert: AssertMethodStub::run is the first frame, ThrowingStub::fail is removed Assert::true(\count($result) < \count($trace)); - Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same(AssertMethodStub::class, $result[0]['class']); Assert::same('run', $result[0]['function']); $hasThrowingFrames = \array_filter( $result, @@ -41,17 +41,17 @@ public function testCutsFramesBelowCutTraceFromExceptionTrace(): void Assert::same([], $hasThrowingFrames); } - public function testCutsFramesBelowCutTraceFromDebugBacktrace(): void + public function testCutsFramesBelowAssertMethodFromDebugBacktrace(): void { // Arrange - $trace = CutTraceStub::run(ThrowingStub::captureTrace(...)); + $trace = AssertMethodStub::run(ThrowingStub::captureTrace(...)); // Act $result = StackTrace::cutStackTrace($trace); - // Assert: CutTraceStub::run is the first frame, ThrowingStub::captureTrace is removed + // Assert: AssertMethodStub::run is the first frame, ThrowingStub::captureTrace is removed Assert::true(\count($result) < \count($trace)); - Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same(AssertMethodStub::class, $result[0]['class']); Assert::same('run', $result[0]['function']); $hasThrowingFrames = \array_filter( $result, @@ -60,7 +60,7 @@ public function testCutsFramesBelowCutTraceFromDebugBacktrace(): void Assert::same([], $hasThrowingFrames); } - public function testDoesNotCutTraceWithoutAttribute(): void + public function testDoesNotAssertMethodWithoutAttribute(): void { // Arrange try { @@ -76,11 +76,11 @@ public function testDoesNotCutTraceWithoutAttribute(): void Assert::same($trace, $result); } - public function testCutsAtOutermostCutTraceWithMultipleAttributes(): void + public function testCutsAtOutermostAssertMethodWithMultipleAttributes(): void { - // Arrange: outer CutTrace -> closure -> inner CutTrace -> fail + // Arrange: outer AssertMethod -> closure -> inner AssertMethod -> fail try { - CutTraceStub::run(static fn() => CutTraceStub::run(ThrowingStub::fail(...))); + AssertMethodStub::run(static fn() => AssertMethodStub::run(ThrowingStub::fail(...))); } catch (\RuntimeException $e) { $trace = $e->getTrace(); } @@ -88,22 +88,22 @@ public function testCutsAtOutermostCutTraceWithMultipleAttributes(): void // Act $result = StackTrace::cutStackTrace($trace); - // Assert: outer CutTrace is the first frame, inner CutTrace and deeper are removed + // Assert: outer AssertMethod is the first frame, inner AssertMethod and deeper are removed Assert::true(\count($result) < \count($trace)); - Assert::same(CutTraceStub::class, $result[0]['class']); + Assert::same(AssertMethodStub::class, $result[0]['class']); Assert::same('run', $result[0]['function']); $cutFrames = \array_filter( $result, - static fn(array $f): bool => ($f['class'] ?? null) === CutTraceStub::class, + static fn(array $f): bool => ($f['class'] ?? null) === AssertMethodStub::class, ); Assert::same(1, \count($cutFrames)); } public function testDoesNotCutBeyondDepthLimit(): void { - // Arrange: CutTrace is beyond SEARCH_DEPTH from the start + // Arrange: AssertMethod is beyond SEARCH_DEPTH from the start try { - CutTraceStub::run(static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...))); + AssertMethodStub::run(static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...))); } catch (\RuntimeException $e) { $trace = $e->getTrace(); } @@ -111,33 +111,33 @@ public function testDoesNotCutBeyondDepthLimit(): void // Act $result = StackTrace::cutStackTrace($trace); - // Assert: CutTrace too far from the error, nothing is cut + // Assert: AssertMethod too far from the error, nothing is cut Assert::same($trace, $result); } - public function testBoundaryStopsCutTraceSearch(): void + public function testBoundaryStopsAssertMethodSearch(): void { - // Arrange: CutTrace -> middleware -> boundary (no CutTrace between error and boundary) + // Arrange: AssertMethod -> middleware -> boundary (no AssertMethod between error and boundary) try { - CutTraceStub::run(static fn() => MiddlewareStub::run(ThrowingStub::fail(...))); + AssertMethodStub::run(static fn() => MiddlewareStub::run(ThrowingStub::fail(...))); } catch (\RuntimeException $e) { $trace = $e->getTrace(); } $boundary = new \ReflectionMethod(MiddlewareStub::class, 'run'); - // Act: boundary stops search before CutTrace is reached + // Act: boundary stops search before AssertMethod is reached $result = StackTrace::cutStackTrace($trace, $boundary, false); - // Assert: trace unchanged — CutTrace is after boundary, not found + // Assert: trace unchanged — AssertMethod is after boundary, not found Assert::same($trace, $result); } - public function testBoundaryWithCutTraceBeforeBoundary(): void + public function testBoundaryWithAssertMethodBeforeBoundary(): void { - // Arrange: error -> CutTrace -> middleware -> boundary + // Arrange: error -> AssertMethod -> middleware -> boundary try { MiddlewareStub::run( - static fn() => CutTraceStub::run(ThrowingStub::fail(...)), + static fn() => AssertMethodStub::run(ThrowingStub::fail(...)), ); } catch (\RuntimeException $e) { $trace = $e->getTrace(); @@ -147,16 +147,16 @@ public function testBoundaryWithCutTraceBeforeBoundary(): void // Act $result = StackTrace::cutStackTrace($trace, $boundary); - // Assert: CutTrace found before boundary, internal frames cut - Assert::same(CutTraceStub::class, $result[0]['class']); + // Assert: AssertMethod found before boundary, internal frames cut + Assert::same(AssertMethodStub::class, $result[0]['class']); Assert::same('run', $result[0]['function']); } public function testBoundaryBypassesDepthLimit(): void { - // Arrange: CutTrace is beyond SEARCH_DEPTH but before boundary + // Arrange: AssertMethod is beyond SEARCH_DEPTH but before boundary try { - MiddlewareStub::run(static fn() => CutTraceStub::run( + MiddlewareStub::run(static fn() => AssertMethodStub::run( static fn() => MiddlewareStub::runDeep(ThrowingStub::fail(...)), )); } catch (\RuntimeException $e) { @@ -167,8 +167,8 @@ public function testBoundaryBypassesDepthLimit(): void // Act $result = StackTrace::cutStackTrace($trace, $boundary); - // Assert: CutTrace found despite being beyond SEARCH_DEPTH - Assert::same(CutTraceStub::class, $result[0]['class']); + // Assert: AssertMethod found despite being beyond SEARCH_DEPTH + Assert::same(AssertMethodStub::class, $result[0]['class']); Assert::same('run', $result[0]['function']); }