diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index d6bdea0d..ff65833f 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -29,6 +29,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 e9ab9aa9..2bbf6751 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "testo/codecov": "^0.1.12", "testo/convention": "^0.1.4", "testo/data": "^0.1.7", + "testo/error-handler": "^0.1", "testo/filter": "^0.1.6", "testo/inline": "^0.1.8", "testo/lifecycle": "^0.1.5", @@ -103,6 +104,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/", @@ -127,6 +129,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 @@ + $errors */ + public function __construct( + public array $errors, + ) {} + + public function isEmpty(): bool + { + return $this->errors === []; + } +} diff --git a/plugin/error-handler/src/ErrorHandlerPlugin.php b/plugin/error-handler/src/ErrorHandlerPlugin.php new file mode 100644 index 00000000..9c40ba38 --- /dev/null +++ b/plugin/error-handler/src/ErrorHandlerPlugin.php @@ -0,0 +1,37 @@ +get(InterceptorCollector::class) + ->addInterceptor(new ErrorHandlerInterceptor($this->failOnError)); + } +} diff --git a/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php new file mode 100644 index 00000000..8e056c27 --- /dev/null +++ b/plugin/error-handler/src/Internal/ErrorHandlerInterceptor.php @@ -0,0 +1,112 @@ + $errors */ + $errors = []; + + $handler = static function (int $severity, string $message, string $file, int $line) use (&$errors): bool { + $errors[] = new CapturedError($severity, $message, $file, $line); + return true; + }; + + $result = $this->run($info, $next, $handler); + + if ($errors === []) { + return $result; + } + + $result = $result->withAttribute(CapturedErrors::class, new CapturedErrors($errors)); + + if ($this->failOnError && $result->status === Status::Passed) { + $first = $errors[0]; + $result = $result + ->with(status: Status::Failed) + ->withFailure(new \ErrorException($first->message, 0, $first->severity, $first->file, $first->line)); + } + + return $result; + } + + /** + * Runs the test with {@see $handler} installed via {@see \set_error_handler()}, keeping it + * bound to this test across fiber suspensions. + * + * set_error_handler()/restore_error_handler() operate on one process-global stack, so under + * concurrent (fiber-based) execution — where sibling tests interleave with this one — a plain + * install-before/restore-after around $next() would leak errors into the wrong test's + * CapturedErrors, and an interleaved resume could pop a sibling's handler instead of ours. On + * every suspension we restore whichever handler was active before this test installed its own + * (the native stack does that for free); on resumption we re-install this test's handler. + * Mirrors {@see \Testo\Bridge\Mockery\Internal\MockeryInterceptor::run()} and + * {@see \Testo\Application\Internal\MessengerHub::scope()}. + * + * @param callable(TestInfo): TestResult $next + */ + private function run(TestInfo $info, callable $next, \Closure $handler): TestResult + { + \set_error_handler($handler); + try { + if (\Fiber::getCurrent() === null) { + return $next($info); + } + + $fiber = new \Fiber(static fn(): TestResult => $next($info)); + $value = $fiber->start(); + while (!$fiber->isTerminated()) { + \restore_error_handler(); + try { + $resume = \Fiber::suspend($value); + } catch (\Throwable $e) { + \set_error_handler($handler); + $value = $fiber->throw($e); + continue; + } + + \set_error_handler($handler); + $value = $fiber->resume($resume); + } + + /** @var TestResult $result */ + $result = $fiber->getReturn(); + return $result; + } finally { + \restore_error_handler(); + } + } +} diff --git a/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php b/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php new file mode 100644 index 00000000..c3e1072b --- /dev/null +++ b/plugin/error-handler/tests/Unit/ErrorHandlerInterceptorTest.php @@ -0,0 +1,276 @@ + 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); + } + + /** + * The error-handler stack is process-global, and Testo can run tests inside fibers with + * sibling tests interleaving on suspend/resume. A plain install-before/restore-after around + * $next() would leave this test's handler installed for the entire suspension window, so a + * sibling's error fired while this test is suspended would wrongly be captured here instead + * of reaching whatever was active before this test started. + */ + public function restoresTheOuterHandlerWhileSuspendedAndReinstallsItsOwnOnResume(): void + { + $interceptor = new ErrorHandlerInterceptor(); + $info = self::createTestInfo(); + + $outerCount = 0; + \set_error_handler(static function () use (&$outerCount): bool { + $outerCount++; + return true; + }); + + try { + $next = static function (TestInfo $info): TestResult { + \trigger_error('before suspend', \E_USER_NOTICE); + \Fiber::suspend(); + \trigger_error('after resume', \E_USER_NOTICE); + return new TestResult(info: $info, status: Status::Passed); + }; + + // runTest() only takes the fiber-aware branch when a fiber is already active, so + // drive it inside our own fiber here — exactly how Testo's scheduler runs a test. + $fiber = new \Fiber(static fn(): TestResult => $interceptor->runTest($info, $next)); + $fiber->start(); + + // While this test is suspended, an error fired by anything else running in the + // process (a sibling test interleaving via the scheduler) must fall through to + // whatever was active before this test installed its own handler. + \trigger_error('fired while suspended', \E_USER_NOTICE); + Assert::same($outerCount, 1); + + $fiber->resume(); + Assert::true($fiber->isTerminated()); + + $result = $fiber->getReturn(); + $errors = $result->getAttribute(CapturedErrors::class); + Assert::instanceOf($errors, CapturedErrors::class); + Assert::same(\count($errors->errors), 2); + Assert::same($errors->errors[0]->message, 'before suspend'); + Assert::same($errors->errors[1]->message, 'after resume'); + } finally { + \restore_error_handler(); + } + } + + private static function createTestInfo(): TestInfo + { + $reflection = new \ReflectionMethod(self::class, 'createTestInfo'); + $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__)); + $caseInfo = new CaseInfo(definition: $caseDefinition, suiteIdentity: new SuiteIdentity('ErrorHandler/Unit')); + $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 @@ +