diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index 658df876..648d8b4e 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -26,6 +26,7 @@ on: # yamllint disable-line rule:truthy - 'convention-[0-9]*' - 'data-[0-9]*' - 'facade-[0-9]*' + - 'error-handler-[0-9]*' - 'filter-[0-9]*' - 'inline-[0-9]*' - 'lifecycle-[0-9]*' diff --git a/composer.json b/composer.json index 3fa9e73f..96d8405b 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "testo/codecov": "^0.1.11", "testo/convention": "^0.1.4", "testo/data": "^0.1.6", + "testo/error-handler": "^0.1", "testo/filter": "^0.1.5", "testo/inline": "^0.1.6", "testo/lifecycle": "^0.1.5", @@ -92,6 +93,7 @@ "Tests\\Convention\\": "plugin/convention/tests/", "Tests\\Data\\": "plugin/data/tests/", "Tests\\Facade\\": "plugin/facade/tests/", + "Tests\\ErrorHandler\\": "plugin/error-handler/tests/", "Tests\\Filter\\": "plugin/filter/tests/", "Tests\\Lifecycle\\": "plugin/lifecycle/tests/", "Tests\\Repeat\\": "plugin/repeat/tests/", @@ -115,6 +117,7 @@ "testo/convention": "0.1.x-dev", "testo/data": "0.1.x-dev", "testo/facade": "0.1.x-dev", + "testo/error-handler": "0.1.x-dev", "testo/filter": "0.1.x-dev", "testo/inline": "0.1.x-dev", "testo/lifecycle": "0.1.x-dev", diff --git a/infection.json b/infection.json index 14366950..56f5a96c 100644 --- a/infection.json +++ b/infection.json @@ -17,5 +17,11 @@ "stryker": { "report": "1.x" } + }, + "mutators": { + "@default": true, + "global-ignoreSourceCodeByRegex": [ + "#\\[TestInline" + ] } } diff --git a/plugin/error-handler/composer.json b/plugin/error-handler/composer.json new file mode 100644 index 00000000..36edb2f0 --- /dev/null +++ b/plugin/error-handler/composer.json @@ -0,0 +1,39 @@ +{ + "name": "testo/error-handler", + "description": "Error handler interceptor plugin for the Testo testing framework.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "error-handler", + "test" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], + "require": { + "php": ">=8.2", + "testo/testo": "0.10.34 - 1" + }, + "autoload": { + "psr-4": { + "Testo\\ErrorHandler\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/plugin/error-handler/src/CapturedError.php b/plugin/error-handler/src/CapturedError.php new file mode 100644 index 00000000..4c1526d7 --- /dev/null +++ b/plugin/error-handler/src/CapturedError.php @@ -0,0 +1,20 @@ +get(InterceptorCollector::class) + ->addInterceptor(new ErrorHandlerInterceptor($this->failOnError)); + } +} diff --git a/plugin/error-handler/src/Internal/CapturedErrors.php b/plugin/error-handler/src/Internal/CapturedErrors.php new file mode 100644 index 00000000..ce7304ea --- /dev/null +++ b/plugin/error-handler/src/Internal/CapturedErrors.php @@ -0,0 +1,29 @@ + $errors */ + public function __construct( + public array $errors, + ) {} + + public function isEmpty(): bool + { + return $this->errors === []; + } +} diff --git a/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php new file mode 100644 index 00000000..3886f294 --- /dev/null +++ b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php @@ -0,0 +1,70 @@ + $errors */ + $errors = []; + + \set_error_handler( + static function (int $severity, string $message, string $file, int $line) use (&$errors): bool { + $errors[] = new CapturedError($severity, $message, $file, $line); + return true; + }, + ); + + try { + $result = $next($info); + } finally { + \restore_error_handler(); + } + + if ($errors === []) { + return $result; + } + + $result = $result->withAttribute(CapturedErrors::class, new CapturedErrors($errors)); + + if ($this->failOnError && !$result->status->isFailure()) { + $first = $errors[0]; + $result = $result + ->with(status: Status::Failed) + ->withFailure(new \ErrorException($first->message, 0, $first->severity, $first->file, $first->line)); + } + + return $result; + } +} diff --git a/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php b/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php new file mode 100644 index 00000000..fec8d021 --- /dev/null +++ b/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php @@ -0,0 +1,223 @@ + new TestResult(info: $info, status: Status::Passed); + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Passed); + Assert::null($result->getAttribute(CapturedErrors::class)); + } + + public function capturedErrorIsStoredAsAttribute(): void + { + $interceptor = new ErrorHandlerInterceptor(); + $info = self::createTestInfo(); + $next = static function (TestInfo $info): TestResult { + \trigger_error('test warning', \E_USER_WARNING); + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Passed); + $errors = $result->getAttribute(CapturedErrors::class); + Assert::instanceOf($errors, CapturedErrors::class); + Assert::false($errors->isEmpty()); + Assert::same(\count($errors->errors), 1); + Assert::same($errors->errors[0]->message, 'test warning'); + Assert::same($errors->errors[0]->severity, \E_USER_WARNING); + } + + public function multipleErrorsAreAllCaptured(): void + { + $interceptor = new ErrorHandlerInterceptor(); + $info = self::createTestInfo(); + $next = static function (TestInfo $info): TestResult { + \trigger_error('first', \E_USER_NOTICE); + \trigger_error('second', \E_USER_WARNING); + \trigger_error('third', \E_USER_DEPRECATED); + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + $errors = $result->getAttribute(CapturedErrors::class); + Assert::instanceOf($errors, CapturedErrors::class); + Assert::same(\count($errors->errors), 3); + Assert::same($errors->errors[0]->message, 'first'); + Assert::same($errors->errors[1]->message, 'second'); + Assert::same($errors->errors[2]->message, 'third'); + } + + public function collectModePreservesPassingStatus(): void + { + $interceptor = new ErrorHandlerInterceptor(failOnError: false); + $info = self::createTestInfo(); + $next = static function (TestInfo $info): TestResult { + \trigger_error('deprecated usage', \E_USER_DEPRECATED); + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Passed); + Assert::notNull($result->getAttribute(CapturedErrors::class)); + } + + public function failModeUpgradesPassingTestToFailed(): void + { + $interceptor = new ErrorHandlerInterceptor(failOnError: true); + $info = self::createTestInfo(); + $next = static function (TestInfo $info): TestResult { + \trigger_error('user warning', \E_USER_WARNING); + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Failed); + Assert::instanceOf($result->failure, \ErrorException::class); + Assert::same($result->failure->getMessage(), 'user warning'); + Assert::same($result->failure->getSeverity(), \E_USER_WARNING); + } + + public function failModeUsesFirstErrorAsFailure(): void + { + $interceptor = new ErrorHandlerInterceptor(failOnError: true); + $info = self::createTestInfo(); + $next = static function (TestInfo $info): TestResult { + \trigger_error('first error', \E_USER_WARNING); + \trigger_error('second error', \E_USER_NOTICE); + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Failed); + Assert::instanceOf($result->failure, \ErrorException::class); + Assert::same($result->failure->getMessage(), 'first error'); + } + + public function failModeDoesNotOverrideAlreadyFailedTest(): void + { + $interceptor = new ErrorHandlerInterceptor(failOnError: true); + $info = self::createTestInfo(); + $originalFailure = new \RuntimeException('assertion failure'); + $next = static function (TestInfo $info) use ($originalFailure): TestResult { + \trigger_error('also an error', \E_USER_WARNING); + return new TestResult(info: $info, status: Status::Failed, failure: $originalFailure); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Failed); + Assert::same($result->failure, $originalFailure); + } + + public function failModeDoesNotOverrideErrorStatus(): void + { + $interceptor = new ErrorHandlerInterceptor(failOnError: true); + $info = self::createTestInfo(); + $originalFailure = new \RuntimeException('unexpected throw'); + $next = static function (TestInfo $info) use ($originalFailure): TestResult { + \trigger_error('also triggered', \E_USER_WARNING); + return new TestResult(info: $info, status: Status::Error, failure: $originalFailure); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Error); + Assert::same($result->failure, $originalFailure); + } + + public function handlerIsRestoredAfterTestCompletes(): void + { + $interceptor = new ErrorHandlerInterceptor(); + $info = self::createTestInfo(); + $next = static fn(TestInfo $info): TestResult => new TestResult(info: $info, status: Status::Passed); + + // Zero-param closure: PHP discards extra arguments silently, avoiding S1172. + $count = 0; + \set_error_handler(static function () use (&$count): bool { + $count++; + return true; + }); + + try { + $interceptor->runTest($info, $next); + \trigger_error('after test', \E_USER_NOTICE); + } finally { + \restore_error_handler(); + } + + Assert::same($count, 1); + } + + public function handlerIsRestoredEvenWhenTestThrows(): void + { + $interceptor = new ErrorHandlerInterceptor(); + $info = self::createTestInfo(); + // Arrow function with no params: throw is a valid expression in PHP 8+. + $next = static fn(): TestResult => throw new \RuntimeException('unexpected throw'); + + $count = 0; + \set_error_handler(static function () use (&$count): bool { + $count++; + return true; + }); + + try { + try { + $interceptor->runTest($info, $next); + } catch (\RuntimeException) { + // expected + } + \trigger_error('after throw', \E_USER_NOTICE); + } finally { + \restore_error_handler(); + } + + Assert::same($count, 1); + } + + private static function createTestInfo(): TestInfo + { + $reflection = new \ReflectionMethod(self::class, 'createTestInfo'); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test'); + $caseInfo = new CaseInfo(definition: $caseDefinition); + $testDefinition = new TestDefinition(reflection: $reflection); + + return new TestInfo( + name: 'testMethod', + caseInfo: $caseInfo, + testDefinition: $testDefinition, + ); + } +} diff --git a/plugin/error-handler/tests/suites.php b/plugin/error-handler/tests/suites.php new file mode 100644 index 00000000..cf7146f9 --- /dev/null +++ b/plugin/error-handler/tests/suites.php @@ -0,0 +1,15 @@ +