diff --git a/src/Application.php b/src/Application.php index ad7c6aa..7798f68 100644 --- a/src/Application.php +++ b/src/Application.php @@ -23,7 +23,7 @@ private function __construct( public static function create( ApplicationConfig $config, - ) { + ): self { $container = Bootstrap::init() ->withConfig($config->services) ->finish(); @@ -40,7 +40,7 @@ public function run($filter = new Filter()): RunResult # Iterate Test Suites foreach ($suiteProvider->withFilter($filter)->getSuites() as $suite) { - $suiteResults[] = $suiteRunner->run($suite, $filter); + $suiteResults[] = $suiteRunner->runSuite($suite, $filter); } # Run suites diff --git a/src/Assert/Interceptor/AssertCollectorInterceptor.php b/src/Assert/Interceptor/AssertCollectorInterceptor.php index e806006..658d9db 100644 --- a/src/Assert/Interceptor/AssertCollectorInterceptor.php +++ b/src/Assert/Interceptor/AssertCollectorInterceptor.php @@ -6,7 +6,7 @@ use Testo\Assert\StaticState; use Testo\Assert\TestState; -use Testo\Interceptor\TestCallInterceptor; +use Testo\Interceptor\TestRunInterceptor; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; @@ -18,7 +18,7 @@ * * Supports both synchronous and asynchronous (Fiber-based) environments. */ -final class AssertCollectorInterceptor implements TestCallInterceptor +final class AssertCollectorInterceptor implements TestRunInterceptor { #[\Override] public function runTest(TestInfo $info, callable $next): TestResult diff --git a/src/Assert/Interceptor/ExpectExceptionConfigurator.php b/src/Assert/Interceptor/ExpectExceptionConfigurator.php index ebd6c3e..a166b51 100644 --- a/src/Assert/Interceptor/ExpectExceptionConfigurator.php +++ b/src/Assert/Interceptor/ExpectExceptionConfigurator.php @@ -7,11 +7,11 @@ use Testo\Assert\State\ExpectedException; use Testo\Assert\StaticState; use Testo\Attribute\ExpectException; -use Testo\Interceptor\TestCallInterceptor; +use Testo\Interceptor\TestRunInterceptor; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; -final class ExpectExceptionConfigurator implements TestCallInterceptor +final class ExpectExceptionConfigurator implements TestRunInterceptor { public function __construct( private readonly ExpectException $options, diff --git a/src/Assert/Interceptor/ExpectExceptionInterceptor.php b/src/Assert/Interceptor/ExpectExceptionInterceptor.php index 153363a..232d0e7 100644 --- a/src/Assert/Interceptor/ExpectExceptionInterceptor.php +++ b/src/Assert/Interceptor/ExpectExceptionInterceptor.php @@ -9,7 +9,7 @@ use Testo\Assert\State\Record; use Testo\Assert\State\Success; use Testo\Assert\StaticState; -use Testo\Interceptor\TestCallInterceptor; +use Testo\Interceptor\TestRunInterceptor; use Testo\Test\Dto\Status; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; @@ -19,7 +19,7 @@ * * @note Must be placed right before the test execution. */ -final class ExpectExceptionInterceptor implements TestCallInterceptor +final class ExpectExceptionInterceptor implements TestRunInterceptor { /** * @throws AssertException When the expected exception is not thrown. diff --git a/src/Attribute/RetryPolicy.php b/src/Attribute/RetryPolicy.php index b0eddbd..74b59ad 100644 --- a/src/Attribute/RetryPolicy.php +++ b/src/Attribute/RetryPolicy.php @@ -4,14 +4,14 @@ namespace Testo\Attribute; -use Testo\Interceptor\TestCallInterceptor\RetryPolicyCallInterceptor; +use Testo\Interceptor\TestCallInterceptor\RetryPolicyRunInterceptor; use Testo\Module\Interceptor\FallbackInterceptor; /** * Retry test on failure. */ -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] -#[FallbackInterceptor(RetryPolicyCallInterceptor::class)] +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_CLASS )] +#[FallbackInterceptor(RetryPolicyRunInterceptor::class)] final class RetryPolicy implements Interceptable { public function __construct( diff --git a/src/Common/Command/Run.php b/src/Common/Command/Run.php index 8d99dc7..5130433 100644 --- a/src/Common/Command/Run.php +++ b/src/Common/Command/Run.php @@ -9,7 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Testo\Render\StdoutRenderer; -use Testo\Render\Teamcity\TeamcityInterceptor; +use Testo\Render\TeamcityInterceptor; #[AsCommand( name: 'run', @@ -23,7 +23,8 @@ public function __invoke( $this->container->bind(StdoutRenderer::class, TeamcityInterceptor::class); $result = $this->application->run(); - tr($result); + // tr($result); + return Command::SUCCESS; } } diff --git a/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php b/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php index 622a5be..008ad91 100644 --- a/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php +++ b/src/Interceptor/Locator/TestoAttributesLocatorInterceptor.php @@ -7,7 +7,7 @@ use Testo\Attribute\Test; use Testo\Interceptor\CaseLocatorInterceptor; use Testo\Interceptor\FileLocatorInterceptor; -use Testo\Module\Tokenizer\Reflection; +use Testo\Interceptor\Reflection\Reflection; use Testo\Module\Tokenizer\Reflection\FileDefinitions; use Testo\Module\Tokenizer\Reflection\TokenizedFile; use Testo\Test\Definition\CaseDefinitions; diff --git a/src/Interceptor/Reflection/AttributesInterceptor.php b/src/Interceptor/Reflection/AttributesInterceptor.php new file mode 100644 index 0000000..551a6d0 --- /dev/null +++ b/src/Interceptor/Reflection/AttributesInterceptor.php @@ -0,0 +1,89 @@ +caseInfo->definition->reflection === null + ? [] + : Reflection::fetchClassAttributes( + class: $info->caseInfo->definition->reflection, + attributeClass: Interceptable::class, + flags: \ReflectionAttribute::IS_INSTANCEOF, + ); + + $methodAttributes = Reflection::fetchFunctionAttributes( + function: $info->testDefinition->reflection, + attributeClass: Interceptable::class, + flags: \ReflectionAttribute::IS_INSTANCEOF, + ); + + $attrs = \array_merge($classAttributes, $methodAttributes); + if ($attrs === []) { + # No attributes, continue to next interceptor + return $next($info); + } + + # Merge and instantiate attributes + $interceptors = $this->interceptorProvider->fromAttributes(TestRunInterceptor::class, ...\array_map( + static fn(\ReflectionAttribute $a): Interceptable => $a->newInstance(), + $attrs, + )); + + return Pipeline::prepare(...$interceptors)->with( + $next, + /** @see TestRunInterceptor::runTest() */ + 'runTest', + )($info); + } + + public function runTestCase(CaseInfo $info, callable $next): CaseResult + { + $attrs = $info->definition->reflection === null + ? [] + : Reflection::fetchClassAttributes( + class: $info->definition->reflection, + attributeClass: Interceptable::class, + flags: \ReflectionAttribute::IS_INSTANCEOF, + ); + + if ($attrs === []) { + # No attributes, continue to next interceptor + return $next($info); + } + + # Merge and instantiate attributes + $interceptors = $this->interceptorProvider->fromAttributes(TestCaseRunInterceptor::class, ...\array_map( + static fn(\ReflectionAttribute $a): Interceptable => $a->newInstance(), + $attrs, + )); + + return Pipeline::prepare(...$interceptors)->with( + $next, + /** @see TestCaseRunInterceptor::runTestCase() */ + 'runTestCase', + )($info); + } +} diff --git a/src/Module/Tokenizer/Reflection.php b/src/Interceptor/Reflection/Reflection.php similarity index 98% rename from src/Module/Tokenizer/Reflection.php rename to src/Interceptor/Reflection/Reflection.php index af9bc2a..d653e71 100644 --- a/src/Module/Tokenizer/Reflection.php +++ b/src/Interceptor/Reflection/Reflection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Testo\Module\Tokenizer; +namespace Testo\Interceptor\Reflection; /** * Reflection utilities. diff --git a/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php b/src/Interceptor/TestCallInterceptor/RetryPolicyRunInterceptor.php similarity index 68% rename from src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php rename to src/Interceptor/TestCallInterceptor/RetryPolicyRunInterceptor.php index ed933f0..0d72965 100644 --- a/src/Interceptor/TestCallInterceptor/RetryPolicyCallInterceptor.php +++ b/src/Interceptor/TestCallInterceptor/RetryPolicyRunInterceptor.php @@ -5,7 +5,7 @@ namespace Testo\Interceptor\TestCallInterceptor; use Testo\Attribute\RetryPolicy; -use Testo\Interceptor\TestCallInterceptor; +use Testo\Interceptor\TestRunInterceptor; use Testo\Test\Dto\Status; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; @@ -15,7 +15,7 @@ * * @see RetryPolicy */ -final class RetryPolicyCallInterceptor implements TestCallInterceptor +final class RetryPolicyRunInterceptor implements TestRunInterceptor { public function __construct( private readonly RetryPolicy $options, @@ -32,13 +32,17 @@ public function runTest(TestInfo $info, callable $next): TestResult /** @var TestResult $result */ $result = $next($info); - if ($result->status->isFailure() && $attempts > 0) { + if ($result->status->isFailure()) { # Test failed, check if we can retry - $isFlaky = true; - goto run; + if ($attempts > 0) { + $isFlaky = true; + goto run; + } + + return $result; } - return $isFlaky && $this->options->markFlaky + return $isFlaky && $this->options->markFlaky && $result->status->isSuccessful() ? $result->with(status: Status::Flaky) : $result; } diff --git a/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php b/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php index a6ec687..85b72da 100644 --- a/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php +++ b/src/Interceptor/TestCaseCallInterceptor/InstantiateTestCase.php @@ -5,14 +5,14 @@ namespace Testo\Interceptor\TestCaseCallInterceptor; use Testo\Interceptor\Exception\TestCaseInstantiationException; -use Testo\Interceptor\TestCaseCallInterceptor; +use Testo\Interceptor\TestCaseRunInterceptor; use Testo\Test\Dto\CaseInfo; use Testo\Test\Dto\CaseResult; /** * Instantiate the test case class if not already instantiated. */ -final class InstantiateTestCase implements TestCaseCallInterceptor +final class InstantiateTestCase implements TestCaseRunInterceptor { public function runTestCase(CaseInfo $info, callable $next): CaseResult { diff --git a/src/Interceptor/TestCaseCallInterceptor.php b/src/Interceptor/TestCaseRunInterceptor.php similarity index 89% rename from src/Interceptor/TestCaseCallInterceptor.php rename to src/Interceptor/TestCaseRunInterceptor.php index 72345e9..cd66ade 100644 --- a/src/Interceptor/TestCaseCallInterceptor.php +++ b/src/Interceptor/TestCaseRunInterceptor.php @@ -13,7 +13,7 @@ * * @extends InterceptorMarker */ -interface TestCaseCallInterceptor extends InterceptorMarker +interface TestCaseRunInterceptor extends InterceptorMarker { /** * @param CaseInfo $info Test case to run. diff --git a/src/Interceptor/TestCallInterceptor.php b/src/Interceptor/TestRunInterceptor.php similarity index 95% rename from src/Interceptor/TestCallInterceptor.php rename to src/Interceptor/TestRunInterceptor.php index dbb773a..9af9764 100644 --- a/src/Interceptor/TestCallInterceptor.php +++ b/src/Interceptor/TestRunInterceptor.php @@ -24,7 +24,7 @@ * * @extends InterceptorMarker */ -interface TestCallInterceptor extends InterceptorMarker +interface TestRunInterceptor extends InterceptorMarker { /** * @param TestInfo $info Information about the test to be run. diff --git a/src/Interceptor/TestSuiteRunInterceptor.php b/src/Interceptor/TestSuiteRunInterceptor.php new file mode 100644 index 0000000..4554e4a --- /dev/null +++ b/src/Interceptor/TestSuiteRunInterceptor.php @@ -0,0 +1,25 @@ + + */ +interface TestSuiteRunInterceptor extends InterceptorMarker +{ + /** + * @param SuiteInfo $info Test suite to run. + * @param callable(SuiteInfo): SuiteResult $next Next interceptor or core logic to run the test suite. + */ + public function runTestSuite(SuiteInfo $info, callable $next): SuiteResult; +} diff --git a/src/Module/Interceptor/InterceptorProvider.php b/src/Module/Interceptor/InterceptorProvider.php index d67350c..e8c88e6 100644 --- a/src/Module/Interceptor/InterceptorProvider.php +++ b/src/Module/Interceptor/InterceptorProvider.php @@ -4,10 +4,15 @@ namespace Testo\Module\Interceptor; +use Testo\Assert\Interceptor\AssertCollectorInterceptor; +use Testo\Assert\Interceptor\ExpectExceptionInterceptor; use Testo\Attribute\Interceptable; use Testo\Common\Container; +use Testo\Interceptor\Reflection\AttributesInterceptor; +use Testo\Interceptor\Reflection\Reflection; +use Testo\Interceptor\TestCaseCallInterceptor\InstantiateTestCase; use Testo\Module\Interceptor\Internal\InterceptorMarker; -use Testo\Module\Tokenizer\Reflection; +use Testo\Render\StdoutRenderer; use Yiisoft\Injector\Injector; final class InterceptorProvider @@ -33,6 +38,26 @@ public static function createDefault(Container $container): self return $self; } + /** + * Get interceptors for the given configuration filtered by the given class. + * + * @template-covariant T of InterceptorMarker + * + * @param class-string $class The target interceptor class. + * + * @return InterceptorMarker Interceptor instances of the given class. + */ + public function fromConfig(string $class): array + { + return $this->fromClasses($class, ...[ + StdoutRenderer::class, + new InstantiateTestCase(), + new AssertCollectorInterceptor(), + AttributesInterceptor::class, + new ExpectExceptionInterceptor(), + ]); + } + /** * Get interceptors for * diff --git a/src/Module/Interceptor/Internal/Pipeline.php b/src/Module/Interceptor/Internal/Pipeline.php index fa04c17..cecb140 100644 --- a/src/Module/Interceptor/Internal/Pipeline.php +++ b/src/Module/Interceptor/Internal/Pipeline.php @@ -23,7 +23,8 @@ final class Pipeline /** @var non-empty-string */ private string $method; - private \Closure $last; + /** @var callable(TInput): TOutput */ + private mixed $last; /** @var TInterceptor */ private array $interceptors = []; @@ -59,7 +60,7 @@ public static function prepare(TInterceptor ...$interceptors): self * * @return callable(object): TOutput */ - public function with(\Closure $last, string $method): callable + public function with(callable $last, string $method): callable { $new = clone $this; diff --git a/src/Render/Teamcity/Formatter.php b/src/Render/Teamcity/Formatter.php new file mode 100644 index 0000000..2f04e3f --- /dev/null +++ b/src/Render/Teamcity/Formatter.php @@ -0,0 +1,324 @@ + $name]; + + $locationHint !== null and $attributes['locationHint'] = $locationHint; + + return self::formatMessage('testSuiteStarted', $attributes); + } + + /** + * Formats a test suite finished message. + * + * @param non-empty-string $name Suite name + * @return non-empty-string + */ + public static function suiteFinished(string $name): string + { + return self::formatMessage('testSuiteFinished', ['name' => $name]); + } + + /** + * Formats a test started message. + * + * @param non-empty-string $name Test name + * @param bool $captureStandardOutput Whether to capture standard output + * @param non-empty-string|null $locationHint Location hint for IDE navigation + * @return non-empty-string + */ + public static function testStarted(string $name, bool $captureStandardOutput = false, ?string $locationHint = null): string + { + $attributes = ['name' => $name]; + + $captureStandardOutput and $attributes['captureStandardOutput'] = 'true'; + $locationHint !== null and $attributes['locationHint'] = $locationHint; + + return self::formatMessage('testStarted', $attributes); + } + + /** + * Formats a test finished message. + * + * @param non-empty-string $name Test name + * @param int<0, max>|null $duration Duration in milliseconds + * @return non-empty-string + */ + public static function testFinished(string $name, ?int $duration = null): string + { + $attributes = ['name' => $name]; + + $duration !== null and $attributes['duration'] = (string) $duration; + + return self::formatMessage('testFinished', $attributes); + } + + /** + * Formats a test failed message. + * + * @param non-empty-string $name Test name + * @param non-empty-string $message Failure message + * @param non-empty-string $details Detailed failure information (stack trace, etc.) + * @param non-empty-string|null $type Comparison type for diff display (e.g., 'comparisonFailure') + * @param non-empty-string|null $expected Expected value for diff + * @param non-empty-string|null $actual Actual value for diff + * @return non-empty-string + */ + public static function testFailed( + string $name, + string $message, + string $details = '', + ?string $type = null, + ?string $expected = null, + ?string $actual = null, + ): string { + $attributes = [ + 'name' => $name, + 'message' => $message, + 'details' => $details, + ]; + + $type !== null and $attributes['type'] = $type; + $expected !== null and $attributes['expected'] = $expected; + $actual !== null and $attributes['actual'] = $actual; + + return self::formatMessage('testFailed', $attributes); + } + + /** + * Formats a test ignored/skipped message. + * + * @param non-empty-string $name Test name + * @param non-empty-string $message Optional skip reason + * @return non-empty-string + */ + public static function testIgnored(string $name, string $message = ''): string + { + $attributes = ['name' => $name]; + + $message !== '' and $attributes['message'] = $message; + + return self::formatMessage('testIgnored', $attributes); + } + + /** + * Formats a test standard output message. + * + * @param non-empty-string $name Test name + * @param non-empty-string $output Standard output content + * @return non-empty-string + */ + public static function testStdOut(string $name, string $output): string + { + return self::formatMessage('testStdOut', [ + 'name' => $name, + 'out' => $output, + ]); + } + + /** + * Formats a test standard error message. + * + * @param non-empty-string $name Test name + * @param non-empty-string $output Standard error content + * @return non-empty-string + */ + public static function testStdErr(string $name, string $output): string + { + return self::formatMessage('testStdErr', [ + 'name' => $name, + 'out' => $output, + ]); + } + + /** + * Formats a progress message. + * + * @param non-empty-string $message Progress message + * @return non-empty-string + */ + public static function progressMessage(string $message): string + { + return self::formatMessage('progressMessage', ['text' => $message]); + } + + /** + * Formats a progress start message. + * + * @param non-empty-string $message Progress message + * @return non-empty-string + */ + public static function progressStart(string $message): string + { + return self::formatMessage('progressStart', ['text' => $message]); + } + + /** + * Formats a progress finish message. + * + * @param non-empty-string $message Progress message + * @return non-empty-string + */ + public static function progressFinish(string $message): string + { + return self::formatMessage('progressFinish', ['text' => $message]); + } + + /** + * Formats a build problem message. + * + * @param non-empty-string $description Problem description + * @param non-empty-string|null $identity Problem identity for deduplication + * @return non-empty-string + */ + public static function buildProblem(string $description, ?string $identity = null): string + { + $attributes = ['description' => $description]; + + $identity !== null and $attributes['identity'] = $identity; + + return self::formatMessage('buildProblem', $attributes); + } + + /** + * Formats a build status message. + * + * @param non-empty-string $text Status text + * @param 'FAILURE'|'SUCCESS'|null $status Build status + * @return non-empty-string + */ + public static function buildStatus(string $text, ?string $status = null): string + { + $attributes = ['text' => $text]; + + $status !== null and $attributes['status'] = $status; + + return self::formatMessage('buildStatus', $attributes); + } + + /** + * Formats a block opened message for grouping output. + * + * @param non-empty-string $name Block name + * @return non-empty-string + */ + public static function blockOpened(string $name): string + { + return self::formatMessage('blockOpened', ['name' => $name]); + } + + /** + * Formats a block closed message. + * + * @param non-empty-string $name Block name + * @return non-empty-string + */ + public static function blockClosed(string $name): string + { + return self::formatMessage('blockClosed', ['name' => $name]); + } + + /** + * Formats a compilation started message. + * + * @param non-empty-string $compiler Compiler name + * @return non-empty-string + */ + public static function compilationStarted(string $compiler): string + { + return self::formatMessage('compilationStarted', ['compiler' => $compiler]); + } + + /** + * Formats a compilation finished message. + * + * @param non-empty-string $compiler Compiler name + * @return non-empty-string + */ + public static function compilationFinished(string $compiler): string + { + return self::formatMessage('compilationFinished', ['compiler' => $compiler]); + } + + /** + * Formats a TeamCity service message. + * + * @param non-empty-string $messageName Message type name + * @param array $attributes Message attributes + * @return non-empty-string + */ + private static function formatMessage(string $messageName, array $attributes): string + { + $formattedAttributes = self::formatAttributes($attributes); + return "##teamcity[{$messageName}{$formattedAttributes}]"; + } + + /** + * Formats message attributes. + * + * @param array $attributes + * @return string Formatted attributes string (may be empty) + */ + private static function formatAttributes(array $attributes): string + { + if ($attributes === []) { + return ''; + } + + $parts = []; + foreach ($attributes as $key => $value) { + $escapedValue = self::escape($value); + $parts[] = " {$key}='{$escapedValue}'"; + } + + return \implode('', $parts); + } + + /** + * Escapes a value for TeamCity service messages. + * + * Special characters that need escaping: + * - ' (apostrophe) -> |' + * - \n (newline) -> |n + * - \r (carriage return) -> |r + * - | (pipe) -> || + * - [ (opening bracket) -> |[ + * - ] (closing bracket) -> |] + * - Unicode characters 0x0000-0x001F -> |0x + */ + private static function escape(string $value): string + { + return \str_replace( + ["|", "'", "\n", "\r", "[", "]"], + ["||", "|'", "|n", "|r", "|[", "|]"], + $value, + ); + } +} diff --git a/src/Render/Teamcity/TeamcityLogger.php b/src/Render/Teamcity/TeamcityLogger.php index 43fcc69..55117c8 100644 --- a/src/Render/Teamcity/TeamcityLogger.php +++ b/src/Render/Teamcity/TeamcityLogger.php @@ -8,62 +8,72 @@ use Testo\Test\Dto\CaseResult; use Testo\Test\Dto\Status; use Testo\Test\Dto\SuiteInfo; +use Testo\Test\Dto\SuiteResult; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; /** - * TeamCity service messages logger for test reporting. + * TeamCity logger for test reporting using DTO objects. * - * Outputs TeamCity-compatible service messages for CI integration. - * Messages follow the format: ##teamcity[messageName name='value' attr='value'] + * Publishes TeamCity service messages based on test execution results. + * Uses TeamcityMessageFormatter for message formatting. * - * @link https://www.jetbrains.com/help/teamcity/service-messages.html + * @see Formatter for message formatting + * @internal */ final class TeamcityLogger { /** - * Outputs a test suite started message. - * - * @param non-empty-string $name Suite name - * @param non-empty-string|null $locationHint Location hint for IDE navigation + * Publishes test suite started message using SuiteInfo. */ - public function suiteStarted(string $name, ?string $locationHint = null): void + public function suiteStartedFromInfo(SuiteInfo $info): void { - $attributes = ['name' => $name]; - - $locationHint !== null and $attributes['locationHint'] = $locationHint; - - $this->message('testSuiteStarted', $attributes); + $this->publish(Formatter::suiteStarted($info->name)); } /** - * Outputs a test suite finished message. - * - * @param non-empty-string $name Suite name + * Publishes test suite finished message using SuiteInfo. */ - public function suiteFinished(string $name): void + public function suiteFinishedFromInfo(SuiteInfo $info): void { - $this->message('testSuiteFinished', ['name' => $name]); + $this->publish(Formatter::suiteFinished($info->name)); } /** - * Outputs a test suite started message using SuiteInfo. + * Handles test suite result. + * + * Analyzes all case results to determine the overall suite status + * and publishes appropriate TeamCity messages. */ - public function suiteStartedFromInfo(SuiteInfo $info): void + public function handleSuiteResult(SuiteInfo $info, SuiteResult $result): void { - $this->suiteStarted($info->name); - } + $hasFailures = false; - /** - * Outputs a test suite finished message using SuiteInfo. - */ - public function suiteFinishedFromInfo(SuiteInfo $info): void - { - $this->suiteFinished($info->name); + foreach ($result as $caseResult) { + foreach ($caseResult as $testResult) { + if ($testResult->status->isFailure()) { + $hasFailures = true; + break 2; + } + } + } + + // Report suite-level failure if any tests failed + if ($hasFailures) { + $failedCount = $this->countFailedTestsInSuite($result); + $this->publish( + Formatter::testStdErr( + $info->name, + "Test suite failed: {$failedCount} test(s) failed", + ), + ); + } + + $this->suiteFinishedFromInfo($info); } /** - * Outputs a test case started message using CaseInfo. + * Publishes test case started message using CaseInfo. * * Test case is treated as a suite in TeamCity (a class containing tests). */ @@ -72,25 +82,25 @@ public function caseStartedFromInfo(CaseInfo $info): void $name = $this->getCaseName($info); $locationHint = $this->getCaseLocationHint($info); - $this->suiteStarted($name, $locationHint); + $this->publish(Formatter::suiteStarted($name, $locationHint)); } /** - * Outputs a test case finished message using CaseInfo. + * Publishes test case finished message using CaseInfo. * * Test case is treated as a suite in TeamCity (a class containing tests). */ public function caseFinishedFromInfo(CaseInfo $info): void { $name = $this->getCaseName($info); - $this->suiteFinished($name); + $this->publish(Formatter::suiteFinished($name)); } /** - * Closes the test case suite based on test results. + * Handles test case result. * * Analyzes all test results to determine the overall case status - * and outputs appropriate TeamCity messages (failed tests, then suite finish). + * and publishes appropriate TeamCity messages. * * @param int<0, max>|null $duration Duration in milliseconds for the case */ @@ -109,9 +119,11 @@ public function handleCaseResult(CaseInfo $caseInfo, CaseResult $result, ?int $d if ($hasFailures) { $caseName = $this->getCaseName($caseInfo); $failedCount = $this->countFailedTests($result); - $this->testStdErr( - $caseName, - "Test case failed: {$failedCount} test(s) failed", + $this->publish( + Formatter::testStdErr( + $caseName, + "Test case failed: {$failedCount} test(s) failed", + ), ); } @@ -119,89 +131,26 @@ public function handleCaseResult(CaseInfo $caseInfo, CaseResult $result, ?int $d } /** - * Outputs a test started message. - * - * @param non-empty-string $name Test name - * @param bool $captureStandardOutput Whether to capture standard output - * @param non-empty-string|null $locationHint Location hint for IDE navigation - */ - public function testStarted(string $name, bool $captureStandardOutput = false, ?string $locationHint = null): void - { - $attributes = ['name' => $name]; - - $captureStandardOutput and $attributes['captureStandardOutput'] = 'true'; - $locationHint !== null and $attributes['locationHint'] = $locationHint; - - $this->message('testStarted', $attributes); - } - - /** - * Outputs a test finished message. - * - * @param non-empty-string $name Test name - * @param int<0, max>|null $duration Duration in milliseconds - */ - public function testFinished(string $name, ?int $duration = null): void - { - $attributes = ['name' => $name]; - - $duration !== null and $attributes['duration'] = (string) $duration; - - $this->message('testFinished', $attributes); - } - - /** - * Outputs a test started message using TestInfo. + * Publishes test started message using TestInfo. */ public function testStartedFromInfo(TestInfo $info, bool $captureStandardOutput = false): void { $locationHint = $this->getTestLocationHint($info); - $this->testStarted($info->name, $captureStandardOutput, $locationHint); + $this->publish(Formatter::testStarted($info->name, $captureStandardOutput, $locationHint)); } /** - * Outputs a test finished message using TestInfo. + * Publishes test finished message using TestInfo. * * @param int<0, max>|null $duration Duration in milliseconds */ public function testFinishedFromInfo(TestInfo $info, ?int $duration = null): void { - $this->testFinished($info->name, $duration); + $this->publish(Formatter::testFinished($info->name, $duration)); } /** - * Outputs a test failed message. - * - * @param non-empty-string $name Test name - * @param non-empty-string $message Failure message - * @param non-empty-string $details Detailed failure information (stack trace, etc.) - * @param non-empty-string|null $type Comparison type for diff display (e.g., 'comparisonFailure') - * @param non-empty-string|null $expected Expected value for diff - * @param non-empty-string|null $actual Actual value for diff - */ - public function testFailed( - string $name, - string $message, - string $details = '', - ?string $type = null, - ?string $expected = null, - ?string $actual = null, - ): void { - $attributes = [ - 'name' => $name, - 'message' => $message, - 'details' => $details, - ]; - - $type !== null and $attributes['type'] = $type; - $expected !== null and $attributes['expected'] = $expected; - $actual !== null and $attributes['actual'] = $actual; - - $this->message('testFailed', $attributes); - } - - /** - * Outputs a test failed message using TestResult. + * Publishes test failed message using TestResult. */ public function testFailedFromResult(TestResult $result): void { @@ -209,183 +158,82 @@ public function testFailedFromResult(TestResult $result): void $message = $failure?->getMessage() ?? 'Test failed'; $details = $failure !== null ? $this->formatThrowable($failure) : ''; - $this->testFailed( - name: $result->info->name, - message: $message, - details: $details, + $this->publish( + Formatter::testFailed( + name: $result->info->name, + message: $message, + details: $details, + ), ); } /** - * Outputs a test ignored/skipped message. - * - * @param non-empty-string $name Test name - * @param non-empty-string $message Optional skip reason - */ - public function testIgnored(string $name, string $message = ''): void - { - $attributes = ['name' => $name]; - - $message !== '' and $attributes['message'] = $message; - - $this->message('testIgnored', $attributes); - } - - /** - * Outputs a test ignored message using TestInfo. + * Publishes test ignored message using TestInfo. * * @param non-empty-string $message Optional skip reason */ public function testIgnoredFromInfo(TestInfo $info, string $message = ''): void { - $this->testIgnored($info->name, $message); + $this->publish(Formatter::testIgnored($info->name, $message)); } /** - * Outputs a test standard output message. - * - * @param non-empty-string $name Test name - * @param non-empty-string $output Standard output content - */ - public function testStdOut(string $name, string $output): void - { - $this->message('testStdOut', [ - 'name' => $name, - 'out' => $output, - ]); - } - - /** - * Outputs a test standard error message. - * - * @param non-empty-string $name Test name - * @param non-empty-string $output Standard error content - */ - public function testStdErr(string $name, string $output): void - { - $this->message('testStdErr', [ - 'name' => $name, - 'out' => $output, - ]); - } - - /** - * Outputs a message based on test status. + * Handles test result and publishes appropriate message based on status. */ public function handleTestResult(TestResult $result, ?int $duration = null): void { match ($result->status) { - Status::Passed, Status::Flaky => $this->testFinished($result->info->name, $duration), - Status::Failed, Status::Error => $this->testFailedFromResult($result), - Status::Skipped => $this->testIgnored($result->info->name), - Status::Risky => $this->handleRiskyTest($result, $duration), - Status::Cancelled => $this->testIgnored($result->info->name, 'Test cancelled'), - Status::Aborted => $this->testFailed( - $result->info->name, - 'Test aborted', - $result->failure !== null ? $this->formatThrowable($result->failure) : '', + Status::Passed, Status::Flaky => $this->publish( + Formatter::testFinished($result->info->name, $duration), ), + Status::Failed, Status::Error => $this->handleFailedTest($result, $duration), + Status::Skipped => $this->handleSkippedTest($result, $duration), + Status::Risky => $this->handleRiskyTest($result, $duration), + Status::Cancelled => $this->handleCancelledTest($result, $duration), + Status::Aborted => $this->handleAbortedTest($result, $duration), }; } /** - * Outputs a progress message. - * - * @param non-empty-string $message Progress message + * Handles skipped test status. */ - public function progressMessage(string $message): void + private function handleSkippedTest(TestResult $result, ?int $duration): void { - $this->message('progressMessage', ['text' => $message]); + $this->publish(Formatter::testIgnored($result->info->name)); + $this->publish(Formatter::testFinished($result->info->name, $duration)); } /** - * Outputs a progress start message. - * - * @param non-empty-string $message Progress message + * Handles cancelled test status. */ - public function progressStart(string $message): void + private function handleCancelledTest(TestResult $result, ?int $duration): void { - $this->message('progressStart', ['text' => $message]); + $this->publish(Formatter::testIgnored($result->info->name, 'Test cancelled')); + $this->publish(Formatter::testFinished($result->info->name, $duration)); } /** - * Outputs a progress finish message. - * - * @param non-empty-string $message Progress message - */ - public function progressFinish(string $message): void - { - $this->message('progressFinish', ['text' => $message]); - } - - /** - * Outputs a build problem message. - * - * @param non-empty-string $description Problem description - * @param non-empty-string|null $identity Problem identity for deduplication + * Handles failed test status. */ - public function buildProblem(string $description, ?string $identity = null): void + private function handleFailedTest(TestResult $result, ?int $duration): void { - $attributes = ['description' => $description]; - - $identity !== null and $attributes['identity'] = $identity; - - $this->message('buildProblem', $attributes); + $this->testFailedFromResult($result); + $this->publish(Formatter::testFinished($result->info->name, $duration)); } /** - * Outputs a build status message. - * - * @param non-empty-string $text Status text - * @param 'FAILURE'|'SUCCESS'|null $status Build status + * Handles aborted test status. */ - public function buildStatus(string $text, ?string $status = null): void + private function handleAbortedTest(TestResult $result, ?int $duration): void { - $attributes = ['text' => $text]; - - $status !== null and $attributes['status'] = $status; - - $this->message('buildStatus', $attributes); - } - - /** - * Outputs a block opened message for grouping output. - * - * @param non-empty-string $name Block name - */ - public function blockOpened(string $name): void - { - $this->message('blockOpened', ['name' => $name]); - } - - /** - * Outputs a block closed message. - * - * @param non-empty-string $name Block name - */ - public function blockClosed(string $name): void - { - $this->message('blockClosed', ['name' => $name]); - } - - /** - * Outputs a compilation started message. - * - * @param non-empty-string $compiler Compiler name - */ - public function compilationStarted(string $compiler): void - { - $this->message('compilationStarted', ['compiler' => $compiler]); - } - - /** - * Outputs a compilation finished message. - * - * @param non-empty-string $compiler Compiler name - */ - public function compilationFinished(string $compiler): void - { - $this->message('compilationFinished', ['compiler' => $compiler]); + $this->publish( + Formatter::testFailed( + $result->info->name, + 'Test aborted', + $result->failure !== null ? $this->formatThrowable($result->failure) : '', + ), + ); + $this->publish(Formatter::testFinished($result->info->name, $duration)); } /** @@ -393,10 +241,12 @@ public function compilationFinished(string $compiler): void */ private function handleRiskyTest(TestResult $result, ?int $duration): void { - $this->testFinished($result->info->name, $duration); - $this->testStdOut( - $result->info->name, - 'Warning: This test has been marked as risky', + $this->publish(Formatter::testFinished($result->info->name, $duration)); + $this->publish( + Formatter::testStdOut( + $result->info->name, + 'Warning: This test has been marked as risky', + ), ); } @@ -431,57 +281,21 @@ private function countFailedTests(CaseResult $result): int } /** - * Outputs a TeamCity service message. - * - * @param non-empty-string $messageName Message type name - * @param array $attributes Message attributes - */ - private function message(string $messageName, array $attributes): void - { - $formattedAttributes = $this->formatAttributes($attributes); - echo "##teamcity[{$messageName}{$formattedAttributes}]\n"; - } - - /** - * Formats message attributes. + * Counts the number of failed tests in a SuiteResult. * - * @param array $attributes - * @return non-empty-string Formatted attributes string + * @return int<0, max> */ - private function formatAttributes(array $attributes): string + private function countFailedTestsInSuite(SuiteResult $result): int { - if ($attributes === []) { - return ''; - } + $count = 0; - $parts = []; - foreach ($attributes as $key => $value) { - $escapedValue = $this->escape($value); - $parts[] = " {$key}='{$escapedValue}'"; + foreach ($result as $caseResult) { + foreach ($caseResult as $testResult) { + $testResult->status->isFailure() and $count++; + } } - return \implode('', $parts); - } - - /** - * Escapes a value for TeamCity service messages. - * - * Special characters that need escaping: - * - ' (apostrophe) -> |' - * - \n (newline) -> |n - * - \r (carriage return) -> |r - * - | (pipe) -> || - * - [ (opening bracket) -> |[ - * - ] (closing bracket) -> |] - * - Unicode characters 0x0000-0x001F -> |0x - */ - private function escape(string $value): string - { - return \str_replace( - ["|", "'", "\n", "\r", "[", "]"], - ["||", "|'", "|n", "|r", "|[", "|]"], - $value, - ); + return $count; } /** @@ -541,4 +355,14 @@ private function getTestLocationHint(TestInfo $info): ?string ? \sprintf('php_qn://%s::\\%s::%s', $file, $className, $methodName) : null; } + + /** + * Publishes a TeamCity service message to stdout. + * + * @param non-empty-string $message Formatted TeamCity message + */ + private function publish(string $message): void + { + echo $message . "\n"; + } } diff --git a/src/Render/Teamcity/TeamcityInterceptor.php b/src/Render/TeamcityInterceptor.php similarity index 57% rename from src/Render/Teamcity/TeamcityInterceptor.php rename to src/Render/TeamcityInterceptor.php index 3f06587..8264528 100644 --- a/src/Render/Teamcity/TeamcityInterceptor.php +++ b/src/Render/TeamcityInterceptor.php @@ -2,17 +2,24 @@ declare(strict_types=1); -namespace Testo\Render\Teamcity; +namespace Testo\Render; -use Testo\Interceptor\TestCallInterceptor; -use Testo\Interceptor\TestCaseCallInterceptor; -use Testo\Render\StdoutRenderer; +use Testo\Interceptor\TestRunInterceptor; +use Testo\Interceptor\TestCaseRunInterceptor; +use Testo\Interceptor\TestSuiteRunInterceptor; +use Testo\Render\Teamcity\TeamcityLogger; use Testo\Test\Dto\CaseInfo; use Testo\Test\Dto\CaseResult; +use Testo\Test\Dto\SuiteInfo; +use Testo\Test\Dto\SuiteResult; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; -final class TeamcityInterceptor implements StdoutRenderer, TestCallInterceptor, TestCaseCallInterceptor +final class TeamcityInterceptor implements + StdoutRenderer, + TestRunInterceptor, + TestCaseRunInterceptor, + TestSuiteRunInterceptor { public function __construct( private readonly TeamcityLogger $logger, @@ -41,4 +48,15 @@ public function runTestCase(CaseInfo $info, callable $next): CaseResult $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; + } } diff --git a/src/Test/Runner/CaseRunner.php b/src/Test/Runner/CaseRunner.php index f1f17e8..bbe81ff 100644 --- a/src/Test/Runner/CaseRunner.php +++ b/src/Test/Runner/CaseRunner.php @@ -5,7 +5,8 @@ namespace Testo\Test\Runner; use Testo\Common\Filter; -use Testo\Interceptor\TestCaseCallInterceptor; +use Testo\Interceptor\TestCaseCallInterceptor\InstantiateTestCase; +use Testo\Interceptor\TestCaseRunInterceptor; use Testo\Module\Interceptor\InterceptorProvider; use Testo\Module\Interceptor\Internal\Pipeline; use Testo\Render\StdoutRenderer; @@ -28,19 +29,14 @@ public function runCase(CaseInfo $info, Filter $filter): CaseResult /** * Prepare interceptors pipeline * - * @see TestCaseCallInterceptor::runTestCase() - * @var list $interceptors + * @see TestCaseRunInterceptor::runTestCase() + * @var list $interceptors * @var callable(CaseInfo): CaseResult $pipeline */ - $interceptors = [ - ...$this->interceptorProvider->fromClasses(TestCaseCallInterceptor::class, StdoutRenderer::class), // todo remove - ...$this->interceptorProvider->fromClasses(TestCaseCallInterceptor::class), - new TestCaseCallInterceptor\InstantiateTestCase(),// todo remove - ]; - + $interceptors = $this->interceptorProvider->fromConfig(TestCaseRunInterceptor::class); $pipeline = Pipeline::prepare(...$interceptors) ->with( - fn(CaseInfo $info): CaseResult => $this->run($info), + $this->run(...), 'runTestCase', ); diff --git a/src/Test/Runner/SuiteRunner.php b/src/Test/Runner/SuiteRunner.php index 18f3f4e..2698b06 100644 --- a/src/Test/Runner/SuiteRunner.php +++ b/src/Test/Runner/SuiteRunner.php @@ -5,6 +5,9 @@ namespace Testo\Test\Runner; use Testo\Common\Filter; +use Testo\Interceptor\TestSuiteRunInterceptor; +use Testo\Module\Interceptor\InterceptorProvider; +use Testo\Module\Interceptor\Internal\Pipeline; use Testo\Test\Dto\CaseInfo; use Testo\Test\Dto\SuiteInfo; use Testo\Test\Dto\SuiteResult; @@ -16,8 +19,28 @@ final class SuiteRunner { public function __construct( private readonly CaseRunner $caseRunner, + private readonly InterceptorProvider $interceptorProvider, ) {} + public function runSuite(SuiteInfo $info, Filter $filter): SuiteResult + { + /** + * Prepare interceptors pipeline + * + * @see TestSuiteRunInterceptor::runTestSuite() + * @var list $interceptors + * @var callable(SuiteInfo): SuiteResult $pipeline + */ + $interceptors = $this->interceptorProvider->fromConfig(TestSuiteRunInterceptor::class); + $pipeline = Pipeline::prepare(...$interceptors) + ->with( + fn(SuiteInfo $info): SuiteResult => $this->run($info, $filter), + 'runTestSuite', + ); + + return $pipeline($info); + } + public function run(SuiteInfo $suite, Filter $filter): SuiteResult { # Apply suite name filter if exists diff --git a/src/Test/Runner/TestRunner.php b/src/Test/Runner/TestRunner.php index c2d8096..152db55 100644 --- a/src/Test/Runner/TestRunner.php +++ b/src/Test/Runner/TestRunner.php @@ -4,14 +4,10 @@ namespace Testo\Test\Runner; -use Testo\Assert\Interceptor\AssertCollectorInterceptor; -use Testo\Assert\Interceptor\ExpectExceptionInterceptor; -use Testo\Attribute\Interceptable; use Testo\Interceptor\Exception\PipelineFailure; -use Testo\Interceptor\TestCallInterceptor; +use Testo\Interceptor\TestRunInterceptor; use Testo\Module\Interceptor\InterceptorProvider; use Testo\Module\Interceptor\Internal\Pipeline; -use Testo\Render\StdoutRenderer; use Testo\Test\Dto\Status; use Testo\Test\Dto\TestInfo; use Testo\Test\Dto\TestResult; @@ -26,12 +22,7 @@ public function runTest(TestInfo $info): TestResult { try { # Build interceptors pipeline - $interceptors = [ - ...$this->interceptorProvider->fromClasses(TestCallInterceptor::class, StdoutRenderer::class), // todo remove - new AssertCollectorInterceptor(), // todo remove - ...$this->prepareInterceptors($info), - new ExpectExceptionInterceptor(), // todo remove - ]; + $interceptors = $this->interceptorProvider->fromConfig(TestRunInterceptor::class); return Pipeline::prepare(...$interceptors)->with( static function (TestInfo $info): TestResult { @@ -56,7 +47,7 @@ static function (TestInfo $info): TestResult { ); } }, - /** @see TestCallInterceptor::runTest() */ + /** @see TestRunInterceptor::runTest() */ 'runTest', )($info); } catch (\Throwable $e) { @@ -67,27 +58,4 @@ static function (TestInfo $info): TestResult { ); } } - - /** - * @return list - */ - private function prepareInterceptors(TestInfo $info): array - { - $classAttributes = $info->caseInfo->definition->reflection?->getAttributes( - Interceptable::class, - \ReflectionAttribute::IS_INSTANCEOF, - ) ?? []; - $methodAttributes = $info->testDefinition->reflection->getAttributes( - Interceptable::class, - \ReflectionAttribute::IS_INSTANCEOF, - ); - - # Merge and instantiate attributes - $attrs = \array_map( - static fn(\ReflectionAttribute $a): Interceptable => $a->newInstance(), - \array_merge($classAttributes, $methodAttributes), - ); - - return $this->interceptorProvider->fromAttributes(TestCallInterceptor::class, ...$attrs); - } } diff --git a/tests/Testo/AsserTest.php b/tests/Testo/AsserTest.php index df7ae13..77cff22 100644 --- a/tests/Testo/AsserTest.php +++ b/tests/Testo/AsserTest.php @@ -30,7 +30,7 @@ public function flaky(): void { static $attempt = 0; ++$attempt; - Assert::same(1, 2); + Assert::same($attempt, 2); } #[Test]